diff --git a/.github/scripts/smoke_test.sh b/.github/scripts/smoke_test.sh new file mode 100755 index 0000000..5eef648 --- /dev/null +++ b/.github/scripts/smoke_test.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Boot the built add-on image the way the Supervisor does and verify that CUPS +# actually comes up, that the printer driver artifacts we compile/patch into the +# image are present, and that the access policy still lets a LAN client reach the +# web UI (the regression fixed in 1.3.2). +# +# Usage: smoke_test.sh +set -euo pipefail + +IMAGE="${1:?usage: smoke_test.sh }" + +SUFFIX="$$-$(date +%s)" +NET="cups-smoke-net-${SUFFIX}" +SERVER="cups-smoke-server-${SUFFIX}" + +pass=0 +fail=0 + +ok() { printf 'ok - %s\n' "$1"; pass=$((pass + 1)); } +bad() { printf 'FAIL - %s\n' "$1"; fail=$((fail + 1)); } + +expect_eq() { # description, actual, expected + if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (want '$3', got '$2')"; fi +} + +expect_contains() { # description, haystack, needle + case "$2" in + *"$3"*) ok "$1" ;; + *) bad "$1 (missing '$3')" ;; + esac +} + +expect_absent() { # description, haystack, needle + case "$2" in + *"$3"*) bad "$1 (unexpectedly found '$3')" ;; + *) ok "$1" ;; + esac +} + +cleanup() { + docker rm -f "$SERVER" >/dev/null 2>&1 || true + docker network rm "$NET" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +server_exec() { docker exec "$SERVER" sh -c "$1"; } + +echo "==> starting $IMAGE on an isolated bridge network" +docker network create "$NET" >/dev/null +docker run -d --name "$SERVER" --network "$NET" "$IMAGE" >/dev/null + +# The Supervisor treats `init: false` services as up once s6 finishes the +# oneshot init; wait for cupsd to answer on its socket instead of guessing. +scheduler="" +for _ in $(seq 1 36); do + scheduler="$(server_exec 'lpstat -r 2>/dev/null' || true)" + case "$scheduler" in + *"scheduler is running"*) break ;; + esac + sleep 5 +done +expect_eq "cupsd scheduler is running" "$scheduler" "scheduler is running" + +expect_eq "container is still running" \ + "$(docker inspect "$SERVER" --format '{{.State.Status}}')" "running" + +if [ "$(server_exec 'curl -s -o /dev/null -w "%{http_code}" --max-time 20 http://127.0.0.1:631/' || echo 000)" = "200" ]; then + ok "cupsd serves the web UI on port 631" +else + bad "cupsd does not serve the web UI on port 631" +fi + +# The declared HEALTHCHECK is what the Supervisor health-checks the add-on with, +# so prove it actually passes rather than trusting the CMD line. +health="" +for _ in $(seq 1 24); do + health="$(docker inspect "$SERVER" --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null || true)" + [ "$health" = "healthy" ] && break + sleep 5 +done +expect_eq "declared HEALTHCHECK reports healthy" "$health" "healthy" + +# --- printer driver artifacts ------------------------------------------------ +# Each of these is either compiled from vendored source or shipped as a PPD in +# rootfs/, so a broken COPY/build step shows up here rather than on a user's Pi. +missing="" +for filter in rastertokpsl raster2dymolw raster2dymolm rastertogutenprint.5.3 gstoraster; do + server_exec "test -x /usr/lib/cups/filter/$filter" || missing="$missing $filter" +done +expect_eq "compiled CUPS filter binaries are present and executable" "$missing" "" + +for ppd in \ + /usr/share/cups/model/lw4xl.ppd \ + /usr/share/cups/model/lw450.ppd \ + /usr/share/cups/model/kyocera/Kyocera_FS-1040GDI.ppd \ + /usr/share/cups/model/kyocera/Kyocera_FS-1060DNGDI.ppd +do + server_exec "test -f $ppd" && ok "PPD present: $ppd" || bad "PPD missing: $ppd" +done + +dymo_count="$(server_exec 'ls /usr/share/cups/model/*.ppd 2>/dev/null | wc -l' || echo 0)" +kyocera_count="$(server_exec 'ls /usr/share/cups/model/kyocera/*.ppd 2>/dev/null | wc -l' || echo 0)" +if [ "${dymo_count:-0}" -ge 20 ]; then + ok "Dymo PPD set installed ($dymo_count)" +else + bad "Dymo PPD set looks truncated (got $dymo_count, want >= 20)" +fi +if [ "${kyocera_count:-0}" -ge 6 ]; then + ok "Kyocera PPD set installed ($kyocera_count)" +else + bad "Kyocera PPD set looks truncated (got $kyocera_count, want >= 6)" +fi + +# --- AirPrint / Avahi -------------------------------------------------------- +# avahi-daemon is started with --daemonize, which always returns 0 even when the +# child then exits (e.g. because D-Bus was not actually usable yet), so poll for +# the process rather than sampling once. +avahi_procs="" +for _ in $(seq 1 12); do + avahi_procs="$(server_exec 'pgrep -a avahi-daemon 2>/dev/null' || true)" + [ -n "$avahi_procs" ] && break + sleep 5 +done +if [ -n "$avahi_procs" ]; then + ok "avahi-daemon is running for AirPrint/Bonjour" +else + bad "avahi-daemon is not running (no process matches 'avahi-daemon')" +fi +if server_exec '[ -S /run/avahi-daemon/socket ]'; then + ok "avahi-daemon control socket exists" +else + bad "avahi-daemon control socket is missing" +fi + +# --- access policy ----------------------------------------------------------- +# Assert the generated cupsd.conf keeps the LAN ranges from 1.3.2 open and does +# not silently degrade into an allow-all policy. +cupsd_conf="$(server_exec 'cat /share/cups/config/cupsd.conf' || true)" +if [ -z "$cupsd_conf" ]; then + bad "could not read the generated cupsd.conf" +else + expect_contains "ACL allows localhost" "$cupsd_conf" "Allow localhost" + expect_contains "ACL allows @LOCAL interfaces" "$cupsd_conf" "Allow @LOCAL" + expect_contains "ACL allows RFC1918 IPv4 ranges" "$cupsd_conf" "Allow 192.168.0.0/16" + expect_contains "ACL allows IPv6 link-local" "$cupsd_conf" "Allow fe80::/10" + expect_contains "ACL allows IPv6 unique-local" "$cupsd_conf" "Allow fd00::/8" + expect_absent "ACL is not an allow-all policy" "$cupsd_conf" "Allow all" + expect_contains "Cancel-Job is authorised in a Policy" \ + "$cupsd_conf" "Cancel-Job Cancel-Jobs" +fi + +# --- reachability from a LAN client ----------------------------------------- +# A second container on the same bridge is the closest stand-in for a LAN +# printer client. Requests must use the server IP: cupsd rejects requests whose +# Host header it does not recognise with 400. +server_ip="$(docker inspect "$SERVER" --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')" +if [ -z "$server_ip" ]; then + bad "could not determine the container IP address" +else + for path in / /printers/ /admin; do + code="$(docker run --rm --network "$NET" --entrypoint /bin/sh "$IMAGE" \ + -c "curl -s -o /dev/null -w '%{http_code}' --max-time 20 http://$server_ip:631$path" \ + 2>/dev/null || echo "000")" + expect_eq "LAN client GET $path" "$code" "200" + done +fi + +if [ "$fail" -ne 0 ]; then + echo + echo "==> diagnostics (container logs)" + docker logs "$SERVER" 2>&1 | tail -60 || true + echo + echo "==> diagnostics (processes)" + server_exec 'ps -ef 2>/dev/null || ps w 2>/dev/null || true' + echo + echo "==> diagnostics (runtime sockets)" + server_exec 'ls -la /run/dbus /run/avahi-daemon 2>&1 || true' +fi + +echo +echo "==> $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/.github/scripts/validate_addon.py b/.github/scripts/validate_addon.py new file mode 100644 index 0000000..02ff042 --- /dev/null +++ b/.github/scripts/validate_addon.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Validate Home Assistant add-on metadata before the container build. + +Catches the mistakes that otherwise only surface inside the Supervisor: +bad YAML/JSON, a missing required key, an unknown CPU arch, and options or +ports that are declared without a matching schema/description entry. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +ADDON_DIR = REPO_ROOT / "cups" +CONFIG_PATH = ADDON_DIR / "config.yaml" +REPOSITORY_PATH = REPO_ROOT / "repository.json" +CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md" + +REQUIRED_KEYS = ("name", "version", "slug", "description", "arch", "startup") +VALID_ARCHES = {"aarch64", "amd64", "armv7", "armhf", "i386"} +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") +CHANGELOG_VERSION = re.compile(r"^##\s+\[(\d+\.\d+\.\d+)\]", re.MULTILINE) +PORT_KEY = re.compile(r"^\d+/(tcp|udp)$") +MAP_ENTRY = re.compile(r"^[A-Za-z0-9_.-]+:(rw|ro)$") + +errors: list[str] = [] + + +def fail(message: str) -> None: + errors.append(message) + + +def load_yaml(path: Path): + if not path.is_file(): + fail(f"{path.relative_to(REPO_ROOT)}: file not found") + return None + try: + return yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + fail(f"{path.relative_to(REPO_ROOT)}: invalid YAML: {exc}") + return None + + +def validate_repository_json() -> None: + if not REPOSITORY_PATH.is_file(): + fail("repository.json: file not found") + return + try: + data = json.loads(REPOSITORY_PATH.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + fail(f"repository.json: invalid JSON: {exc}") + return + for key in ("name", "url", "maintainer"): + if not data.get(key): + fail(f"repository.json: missing or empty '{key}'") + + +def validate_arch(config: dict) -> None: + arch = config.get("arch") + if not isinstance(arch, list) or not arch: + fail("config.yaml: 'arch' must be a non-empty list") + return + if len(set(arch)) != len(arch): + fail(f"config.yaml: 'arch' has duplicates: {arch}") + unknown = [a for a in arch if a not in VALID_ARCHES] + if unknown: + fail(f"config.yaml: unknown arch value(s) {unknown}; allowed: {sorted(VALID_ARCHES)}") + if "amd64" not in arch: + fail("config.yaml: 'arch' must include amd64 (CI builds linux/amd64)") + + +def validate_version(config: dict) -> None: + version = str(config.get("version", "")) + if not SEMVER.match(version): + fail(f"config.yaml: 'version' must be X.Y.Z, got {version!r}") + return + if not CHANGELOG_PATH.is_file(): + fail("CHANGELOG.md: file not found") + return + match = CHANGELOG_VERSION.search(CHANGELOG_PATH.read_text(encoding="utf-8")) + if not match: + fail("CHANGELOG.md: no '## [X.Y.Z]' release heading found") + elif match.group(1) != version: + fail( + f"version mismatch: config.yaml has {version}, " + f"CHANGELOG.md latest heading is {match.group(1)}" + ) + + +def validate_symmetry(config: dict, declared: str, described: str) -> None: + left = config.get(declared) + right = config.get(described) + if left is None and right is None: + return + if not isinstance(left, dict) or not isinstance(right, dict): + fail(f"config.yaml: '{declared}' and '{described}' must both be mappings") + return + for key in sorted(set(left) - set(right)): + fail(f"config.yaml: '{declared}' entry {key!r} has no '{described}' entry") + for key in sorted(set(right) - set(left)): + fail(f"config.yaml: '{described}' entry {key!r} has no '{declared}' entry") + + +def validate_options_schema(config: dict) -> None: + options = config.get("options") or {} + schema = config.get("schema") or {} + if not isinstance(options, dict) or not isinstance(schema, dict): + fail("config.yaml: 'options' and 'schema' must both be mappings") + return + for key in sorted(set(options) - set(schema)): + fail(f"config.yaml: option {key!r} has no 'schema' entry") + for key in sorted(set(schema) - set(options)): + fail(f"config.yaml: schema entry {key!r} has no 'options' default") + + +def validate_ports(config: dict) -> None: + validate_symmetry(config, "ports", "ports_description") + for key in sorted(config.get("ports") or {}): + if not PORT_KEY.match(str(key)): + fail(f"config.yaml: port key {key!r} must look like '631/tcp'") + + +def validate_map(config: dict) -> None: + for entry in config.get("map") or []: + if not MAP_ENTRY.match(str(entry)): + fail(f"config.yaml: map entry {entry!r} must look like 'share:rw'") + + +def validate_slug(config: dict) -> None: + slug = config.get("slug") + if slug != ADDON_DIR.name: + fail(f"config.yaml: slug {slug!r} must match add-on directory {ADDON_DIR.name!r}") + + +def main() -> int: + validate_repository_json() + + config = load_yaml(CONFIG_PATH) + if config is None: + for message in errors: + print(f"::error::{message}") + return 1 + if not isinstance(config, dict): + print("::error::config.yaml: top level must be a mapping") + return 1 + + for key in REQUIRED_KEYS: + if config.get(key) in (None, "", [], {}): + fail(f"config.yaml: missing or empty required key '{key}'") + + validate_slug(config) + validate_arch(config) + validate_version(config) + validate_options_schema(config) + validate_ports(config) + validate_map(config) + + if errors: + for message in errors: + print(f"::error::{message}") + print(f"\n{len(errors)} add-on metadata problem(s) found.") + return 1 + + print(f"add-on metadata OK: {ADDON_DIR.name} {config['version']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000..5e8577b --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,29 @@ +name: auto-merge + +on: + pull_request: + types: [opened, ready_for_review, reopened, synchronize] + +permissions: + contents: write + pull-requests: write + +jobs: + enable-auto-merge: + runs-on: ubuntu-latest + if: >- + !github.event.pull_request.draft && + github.event.pull_request.user.login == github.repository_owner + steps: + - name: Mint app installation token + id: app-token + uses: actions/create-github-app-token@v3.2.0 + with: + app-id: ${{ vars.AUTO_MERGE_APP_ID }} + private-key: ${{ secrets.AUTO_MERGE_APP_PRIVATE_KEY }} + + - name: Enable auto-merge (squash) + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: gh pr merge --auto --squash "$PR_URL" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d2591ea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Job id and name are both deliberately `ci`: this is the status check + # context that branch protection on main requires before auto-merge fires. + # Keep it a single non-matrix job so the context stays exactly `ci`. + ci: + name: ci + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Install validation dependencies + run: python3 -m pip install --quiet pyyaml + + - name: Validate add-on metadata + run: python3 .github/scripts/validate_addon.py + + - name: Validate init scripts + run: | + set -euo pipefail + for script in cups/rootfs/etc/cont-init.d/*; do + echo "bash -n $script" + bash -n "$script" + done + + - name: ShellCheck init scripts + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y -qq shellcheck + # -s bash: the s6 shebang (`#!/usr/bin/with-contenv bash`) is not + # recognized by ShellCheck and would otherwise trip SC1008. + shellcheck --severity=error -s bash cups/rootfs/etc/cont-init.d/* + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build add-on image (amd64) + uses: docker/build-push-action@v6 + with: + context: cups + platforms: linux/amd64 + load: true + tags: cups-addon:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke test image + run: .github/scripts/smoke_test.sh cups-addon:ci