Merge remote-tracking branch 'origin/master' into zmq-redis-pubsub-1

# Conflicts:
#	viewer/__init__.py
This commit is contained in:
Viktor Petersson
2026-04-28 08:36:35 +00:00
14 changed files with 187 additions and 68 deletions

View File

@@ -37,6 +37,15 @@ jobs:
buildx:
needs: run-tests
# Scoped per-job (not at workflow level) so `run-tests` and any
# future read-only job don't inherit `packages: write`. `buildx`
# needs it so `docker login ghcr.io` with GITHUB_TOKEN can push
# ghcr.io/screenly/anthias-*. `contents: read` is the implicit
# default but pinned explicitly so a future workflow edit can't
# silently lose checkout access.
permissions:
contents: read
packages: write
strategy:
# Don't cancel sibling jobs on the first failure: any platform that
# has already finished building its image will have pushed the
@@ -106,6 +115,14 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: success() && github.event_name == 'push'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Container
env:
DOCKER_BUILDKIT: 1
@@ -132,13 +149,31 @@ jobs:
# checks every <short-hash>-<board> tag is resolvable before any
# retag fires, so a missing source tag fails the job before it
# mutates the registry. `imagetools create` re-points the registry
# tag to the existing manifest without re-uploading layers, so the
# full ~98 retags (7 boards × 7 services × 2 namespaces) finish in
# under two minutes.
# tag to the existing manifest without re-uploading layers, and each
# call is wrapped in 5-attempt exponential backoff so a transient
# 429 / 5xx doesn't strand latest-* half-mirrored.
#
# Two registries: `ghcr.io/screenly/anthias-*` is the new primary
# (rate-limit-friendly, free unlimited storage for public packages,
# auth via the workflow-issued GITHUB_TOKEN with `packages: write`).
# `screenly/anthias-*` on Docker Hub stays in the loop as a parallel
# mirror during the migration window so devices that haven't yet
# picked up the compose-template flip keep getting `latest-*`
# advanced. GHCR is mirrored first so the canonical source is up to
# date even if Docker Hub flakes on a later retag. The legacy
# `screenly/srly-ose-*` namespace was dropped: every device that
# has run `upgrade_containers.sh` since 2023-02 (b9998438) is on
# `screenly/anthias-*` already, so the parallel `srly-ose-*` push
# was buying no real back-compat in exchange for half the manifest
# GETs (one of two reasons d568602's publish hit Docker Hub's 429).
publish-latest:
needs: buildx
if: github.event_name == 'push'
runs-on: ubuntu-24.04
# See `buildx.permissions` above for rationale; same scoping reason.
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -152,13 +187,44 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Mirror short-hash tags to latest-<board>
run: |
set -euo pipefail
GIT_SHORT_HASH=$(git rev-parse --short=7 HEAD)
BOARDS=(pi1 pi2 pi3 pi4 pi4-64 pi5 x86)
SERVICES=(server celery redis viewer wifi-connect)
NAMESPACES=(screenly/anthias screenly/srly-ose)
# GHCR first so the canonical primary is current even if the
# Docker Hub mirror later in the loop flakes.
NAMESPACES=(ghcr.io/screenly/anthias screenly/anthias)
# Wrap a registry op in 5 attempts of exponential backoff
# (2s, 4s, 8s, 16s) so a transient 429 / 5xx doesn't strand
# `latest-*` half-mirrored. Both `imagetools inspect` and
# `imagetools create` are idempotent: inspect is read-only,
# and create overwrites the tag with the same manifest
# digest on retry.
retry() {
local attempt
for attempt in 1 2 3 4 5; do
if "$@"; then
return 0
fi
if [ "${attempt}" -lt 5 ]; then
local delay=$((2 ** attempt))
echo "Attempt ${attempt} failed; retrying in ${delay}s..." >&2
sleep "${delay}"
fi
done
echo "Giving up after 5 attempts: $*" >&2
return 1
}
echo "::group::Preflight: verify every <short-hash>-<board> source tag exists"
for namespace in "${NAMESPACES[@]}"; do
@@ -166,7 +232,7 @@ jobs:
for board in "${BOARDS[@]}"; do
src="${namespace}-${service}:${GIT_SHORT_HASH}-${board}"
echo "Verifying ${src}"
docker buildx imagetools inspect --raw "${src}" >/dev/null
retry docker buildx imagetools inspect --raw "${src}" >/dev/null
done
done
done
@@ -179,7 +245,7 @@ jobs:
src="${namespace}-${service}:${GIT_SHORT_HASH}-${board}"
dst="${namespace}-${service}:latest-${board}"
echo "Mirroring ${src} -> ${dst}"
docker buildx imagetools create -t "${dst}" "${src}"
retry docker buildx imagetools create -t "${dst}" "${src}"
done
done
done

View File

@@ -12,12 +12,24 @@ import secrets
from os import getenv
from pathlib import Path
import django_stubs_ext
import pytz
from settings import settings as device_settings
django_stubs_ext.monkeypatch()
# django_stubs_ext.monkeypatch() makes Django generic classes
# subscriptable at runtime, and the server side of this repo relies on
# that — anthias_app/admin.py defines `class AssetAdmin(admin.ModelAdmin
# [Asset])` at import time, which raises TypeError without the patch.
# Keep the import optional so the viewer image (and any future service
# that doesn't ship django-stubs-ext) can still load this settings
# module; do not remove the patch as a no-op.
try:
import django_stubs_ext
django_stubs_ext.monkeypatch()
except ModuleNotFoundError as exc:
if exc.name != 'django_stubs_ext':
raise
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -66,21 +78,34 @@ ALLOWED_HOSTS = [
# Application definition
# Apps every Django consumer needs: ORM access to the Asset model,
# plus the contenttypes + auth tables those models implicitly depend
# on. Loaded by every service that calls django.setup() — server,
# celery, viewer, test.
INSTALLED_APPS = [
'channels',
'anthias_app.apps.AnthiasAppConfig',
'drf_spectacular',
'rest_framework',
'api.apps.ApiConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'dbbackup',
'django.contrib.auth',
]
# Apps only the HTTP-serving services need (REST API, OpenAPI schema,
# Channels for WebSockets, the admin UI, sessions/messages, static
# files, DB backups). The viewer never serves HTTP, so it skips these
# at django.setup() time and the viewer image doesn't have to ship
# the packages they live in. Server/celery/test images are unaffected.
if getenv('ANTHIAS_SERVICE') != 'viewer':
INSTALLED_APPS += [
'channels',
'drf_spectacular',
'rest_framework',
'api.apps.ApiConfig',
'django.contrib.admin',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'dbbackup',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',

View File

@@ -8,20 +8,17 @@ import sh
from celery import Celery
from tenacity import Retrying, stop_after_attempt, wait_fixed
try:
django.setup()
django.setup()
# Place imports that uses Django in this block.
# Place imports that uses Django in this block.
from lib import diagnostics
from lib.utils import (
connect_to_redis,
is_balena_app,
reboot_via_balena_supervisor,
shutdown_via_balena_supervisor,
)
except Exception:
pass
from lib import diagnostics # noqa: E402
from lib.utils import ( # noqa: E402
connect_to_redis,
is_balena_app,
reboot_via_balena_supervisor,
shutdown_via_balena_supervisor,
)
__author__ = 'Screenly, Inc'

View File

@@ -3,7 +3,7 @@
version: "2"
services:
anthias-wifi-connect:
image: screenly/anthias-wifi-connect:${GIT_SHORT_HASH}-${BOARD}
image: ghcr.io/screenly/anthias-wifi-connect:${GIT_SHORT_HASH}-${BOARD}
build:
context: .
dockerfile: ./docker/Dockerfile.wifi-connect
@@ -21,7 +21,7 @@ services:
io.balena.features.firmware: "1"
anthias-server:
image: screenly/anthias-server:${GIT_SHORT_HASH}-${BOARD}
image: ghcr.io/screenly/anthias-server:${GIT_SHORT_HASH}-${BOARD}
build:
context: .
dockerfile: ./docker/Dockerfile.server
@@ -43,7 +43,7 @@ services:
io.balena.features.supervisor-api: '1'
anthias-viewer:
image: screenly/anthias-viewer:${GIT_SHORT_HASH}-${BOARD}
image: ghcr.io/screenly/anthias-viewer:${GIT_SHORT_HASH}-${BOARD}
build:
context: .
dockerfile: ./docker/Dockerfile.viewer
@@ -65,7 +65,7 @@ services:
io.balena.features.supervisor-api: '1'
anthias-celery:
image: screenly/anthias-celery:${GIT_SHORT_HASH}-${BOARD}
image: ghcr.io/screenly/anthias-celery:${GIT_SHORT_HASH}-${BOARD}
build:
context: .
dockerfile: ./docker/Dockerfile.celery
@@ -85,7 +85,7 @@ services:
io.balena.features.supervisor-api: '1'
redis:
image: screenly/anthias-redis:${GIT_SHORT_HASH}-${BOARD}
image: ghcr.io/screenly/anthias-redis:${GIT_SHORT_HASH}-${BOARD}
build:
context: .
dockerfile: ./docker/Dockerfile.redis

View File

@@ -3,7 +3,7 @@
version: "2"
services:
anthias-wifi-connect:
image: screenly/anthias-wifi-connect:${GIT_SHORT_HASH}-${BOARD}
image: ghcr.io/screenly/anthias-wifi-connect:${GIT_SHORT_HASH}-${BOARD}
depends_on:
- anthias-viewer
environment:
@@ -18,7 +18,7 @@ services:
io.balena.features.firmware: "1"
anthias-server:
image: screenly/anthias-server:${GIT_SHORT_HASH}-${BOARD}
image: ghcr.io/screenly/anthias-server:${GIT_SHORT_HASH}-${BOARD}
ports:
- 80:8080
environment:
@@ -37,7 +37,7 @@ services:
io.balena.features.supervisor-api: '1'
anthias-viewer:
image: screenly/anthias-viewer:${GIT_SHORT_HASH}-${BOARD}
image: ghcr.io/screenly/anthias-viewer:${GIT_SHORT_HASH}-${BOARD}
depends_on:
- anthias-server
environment:
@@ -56,7 +56,7 @@ services:
io.balena.features.supervisor-api: '1'
anthias-celery:
image: screenly/anthias-celery:${GIT_SHORT_HASH}-${BOARD}
image: ghcr.io/screenly/anthias-celery:${GIT_SHORT_HASH}-${BOARD}
depends_on:
- anthias-server
- redis
@@ -73,7 +73,7 @@ services:
io.balena.features.supervisor-api: '1'
redis:
image: screenly/anthias-redis:${GIT_SHORT_HASH}-${BOARD}
image: ghcr.io/screenly/anthias-redis:${GIT_SHORT_HASH}-${BOARD}
ports:
- 127.0.0.1:6379:6379
restart: always

View File

@@ -2,7 +2,7 @@
services:
anthias-wifi-connect:
image: screenly/anthias-wifi-connect:${DOCKER_TAG}-${DEVICE_TYPE}
image: ghcr.io/screenly/anthias-wifi-connect:${DOCKER_TAG}-${DEVICE_TYPE}
build:
context: .
dockerfile: docker/Dockerfile.wifi-connect
@@ -21,7 +21,7 @@ services:
target: /run/dbus/system_bus_socket
anthias-server:
image: screenly/anthias-server:${DOCKER_TAG}-${DEVICE_TYPE}
image: ghcr.io/screenly/anthias-server:${DOCKER_TAG}-${DEVICE_TYPE}
build:
context: .
dockerfile: docker/Dockerfile.server
@@ -51,7 +51,7 @@ services:
io.balena.features.supervisor-api: '1'
anthias-viewer:
image: screenly/anthias-viewer:${DOCKER_TAG}-${DEVICE_TYPE}
image: ghcr.io/screenly/anthias-viewer:${DOCKER_TAG}-${DEVICE_TYPE}
build:
context: .
dockerfile: docker/Dockerfile.viewer
@@ -79,7 +79,7 @@ services:
io.balena.features.supervisor-api: '1'
anthias-celery:
image: screenly/anthias-celery:${DOCKER_TAG}-${DEVICE_TYPE}
image: ghcr.io/screenly/anthias-celery:${DOCKER_TAG}-${DEVICE_TYPE}
build:
context: .
dockerfile: docker/Dockerfile.celery
@@ -103,7 +103,7 @@ services:
io.balena.features.supervisor-api: '1'
redis:
image: screenly/anthias-redis:${DOCKER_TAG}-${DEVICE_TYPE}
image: ghcr.io/screenly/anthias-redis:${DOCKER_TAG}-${DEVICE_TYPE}
build:
context: .
dockerfile: docker/Dockerfile.redis

View File

@@ -19,3 +19,4 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
# https://github.com/balena-io-library/base-images/issues/562
RUN c_rehash
{% include 'labels.j2' %}

View File

@@ -19,5 +19,7 @@ ENV GIT_BRANCH={{ git_branch }}
RUN sed -i 's/^bind.*/bind 0.0.0.0/g' /etc/redis/redis.conf
RUN sed -i 's/^protected-mode.*/protected-mode no/g' /etc/redis/redis.conf
{% include 'labels.j2' %}
CMD ["redis-server", "--protected-mode", "no"]

View File

@@ -61,6 +61,7 @@ ENV GIT_SHORT_HASH={{ git_short_hash }}
ENV GIT_BRANCH={{ git_branch }}
ENV DEVICE_TYPE={{ board }}
ENV DJANGO_SETTINGS_MODULE="anthias_django.settings"
ENV ANTHIAS_SERVICE="viewer"
RUN useradd -g video viewer
@@ -70,4 +71,6 @@ WORKDIR /usr/src/app
RUN mkdir -p /usr/src/app
COPY . /usr/src/app/
{% include 'labels.j2' %}
CMD ["bash", "./bin/start_viewer.sh"]

19
docker/labels.j2 Normal file
View File

@@ -0,0 +1,19 @@
{# OCI image labels (https://specs.opencontainers.org/image-spec/annotations/).
`image.source` is the load-bearing one for GitHub Container Registry:
GHCR uses it to link the package to its source repo, which makes the
package inherit the repo's visibility and grants repo collaborators
push/delete access. Without it, packages stay private even when the
repo is public, and the package page on GitHub has no link back. #}
{% set service_descriptions = {
'server': 'Anthias web server (uvicorn + Django + Channels)',
'celery': 'Anthias background task worker (asset downloads, cleanup, display power)',
'redis': 'Redis broker for Anthias Celery and Channels',
'viewer': 'Anthias display/viewer service',
'wifi-connect': 'Anthias WiFi captive-portal helper',
'test': 'Anthias test runner',
} %}
LABEL org.opencontainers.image.source="https://github.com/Screenly/Anthias"
LABEL org.opencontainers.image.url="https://anthias.screenly.io"
LABEL org.opencontainers.image.licenses="GPL-2.0"
LABEL org.opencontainers.image.title="anthias-{{ service }}"
LABEL org.opencontainers.image.description="{{ service_descriptions.get(service, 'Anthias ' + service) }}"

View File

@@ -78,8 +78,6 @@ viewer = [
"cryptography==46.0.7",
"Cython==3.2.4",
"Django==4.2.30",
"django-dbbackup==4.2.1",
"drf-spectacular==0.29.0",
"future==1.0.0",
"idna==3.11",
"Jinja2==3.1.6",

View File

@@ -106,6 +106,7 @@ def build_image(
'git_branch': git_branch,
'git_hash': git_hash,
'git_short_hash': git_short_hash,
'service': service,
'target_platform': target_platform,
**context,
},
@@ -236,8 +237,22 @@ def main(
# Build Docker images
for service_name in services_to_build:
# Define tag components
namespaces = ['screenly/anthias', 'screenly/srly-ose']
# Define tag components.
#
# GHCR is listed first because it is the primary, canonical source
# for Anthias images going forward — `bin/upgrade_containers.sh`
# regenerates compose from `docker-compose.yml.tmpl`, so flipping
# the template (separate change) flips every device on next
# upgrade. Docker Hub stays in the list as a parallel push during
# the migration window so devices that haven't yet picked up the
# template flip keep getting `latest-*` advanced.
#
# The legacy `screenly/srly-ose-*` namespace was dropped: every
# device that has run `upgrade_containers.sh` since 2023-02
# (b9998438) is on `screenly/anthias-*`, and stale `srly-ose-*`
# `latest-*` mirroring (one of two reasons d568602 hit Docker
# Hub's 429) gives no real back-compat in exchange.
namespaces = ['ghcr.io/screenly/anthias', 'screenly/anthias']
version_suffix = (
f'{board}-64'
if board == 'pi4' and platform == 'linux/arm64/v8'

4
uv.lock generated
View File

@@ -267,8 +267,6 @@ viewer = [
{ name = "cryptography" },
{ name = "cython" },
{ name = "django" },
{ name = "django-dbbackup" },
{ name = "drf-spectacular" },
{ name = "future" },
{ name = "idna" },
{ name = "jinja2" },
@@ -468,8 +466,6 @@ viewer = [
{ name = "cryptography", specifier = "==46.0.7" },
{ name = "cython", specifier = "==3.2.4" },
{ name = "django", specifier = "==4.2.30" },
{ name = "django-dbbackup", specifier = "==4.2.1" },
{ name = "drf-spectacular", specifier = "==0.29.0" },
{ name = "future", specifier = "==1.0.0" },
{ name = "idna", specifier = "==3.11" },
{ name = "jinja2", specifier = "==3.1.6" },

View File

@@ -32,23 +32,20 @@ from viewer.utils import (
watchdog,
)
try:
django.setup()
django.setup()
# Place imports that uses Django in this block.
# Place imports that uses Django in this block.
from lib.utils import (
connect_to_redis,
get_balena_device_info,
get_node_ip,
is_balena_app,
string_to_bool,
url_fails,
)
from viewer.messaging import ViewerSubscriber
from viewer.scheduling import Scheduler
except Exception:
pass
from lib.utils import ( # noqa: E402
connect_to_redis,
get_balena_device_info,
get_node_ip,
is_balena_app,
string_to_bool,
url_fails,
)
from viewer.messaging import ViewerSubscriber # noqa: E402
from viewer.scheduling import Scheduler # noqa: E402
__author__ = 'Screenly, Inc'