Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions .github/scripts/smoke_test.sh
Original file line number Diff line number Diff line change
@@ -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 <image>
set -euo pipefail

IMAGE="${1:?usage: smoke_test.sh <image>}"

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 ]
174 changes: 174 additions & 0 deletions .github/scripts/validate_addon.py
Original file line number Diff line number Diff line change
@@ -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())
29 changes: 29 additions & 0 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading