diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a08b613..7f0bdbc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,9 @@ name: build on: push: branches: [main] + # A version tag also releases: the image, the .deb, the Imager listing + # and the apt repo (the release job below; docs/releasing.md). + tags: ['v*'] pull_request: permissions: @@ -68,3 +71,112 @@ jobs: deploy/*.img.xz deploy/*.img.xz.sha256 deploy/*.img.info + deploy/*.deb + + release: + # Tags only: publish what the image job built and smoke-tested + # (MAD-809/MAD-810, ADR 0011 and 0012). + if: startsWith(github.ref, 'refs/tags/v') + needs: [tests, image] + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write # the GitHub Release and the gh-pages push + id-token: write # cosign keyless signing (GitHub OIDC) + env: + GH_TOKEN: ${{ github.token }} + PAGES_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v4 + with: + name: syncphony-box-image-${{ github.sha }} + path: deploy + + - name: Check the release inputs + env: + APT_SIGNING_KEY: ${{ secrets.APT_SIGNING_KEY }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + # A release image must be able to update itself. + test -f packaging/apt/syncphony-box-archive-keyring.asc || { + echo "::error::packaging/apt/syncphony-box-archive-keyring.asc is missing (docs/releasing.md)"; exit 1; } + test -n "${APT_SIGNING_KEY}" || { + echo "::error::the APT_SIGNING_KEY secret is not set (docs/releasing.md)"; exit 1; } + scripts/changelog-section.sh "${VERSION}" > notes.md + IMAGE="deploy/syncphony-box-${GITHUB_REF_NAME}-arm64.img.xz" + DEB="deploy/syncphony-box_${VERSION}_arm64.deb" + ls -l "${IMAGE}" "${DEB}" + echo "IMAGE=${IMAGE}" >> "$GITHUB_ENV" + echo "DEB=${DEB}" >> "$GITHUB_ENV" + + - uses: sigstore/cosign-installer@v3 + with: + cosign-release: v2.4.1 + + - name: Checksums, signed + run: | + set -euo pipefail + cd deploy + sha256sum "$(basename "${IMAGE}")" "$(basename "${DEB}")" > SHA256SUMS + # Keyless: the signature binds SHA256SUMS to this workflow run at + # this tag (Sigstore's transparency log records it). + cosign sign-blob --yes --bundle SHA256SUMS.sigstore.json SHA256SUMS + + - name: Imager listing + run: | + set -euo pipefail + mkdir -p imager + python3 -I scripts/imager-os-list.py "${IMAGE}" \ + --url "https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/$(basename "${IMAGE}")" \ + --icon "${PAGES_URL}/imager/icon.png" \ + --version "${VERSION}" \ + --release-date "$(git log -1 --format=%cs "${GITHUB_REF_NAME}")" \ + > imager/os_list.json + cat imager/os_list.json + + - name: GitHub Release + run: | + set -euo pipefail + gh release create "${GITHUB_REF_NAME}" \ + --title "Syncphony Box ${VERSION}" \ + --notes-file notes.md \ + --verify-tag \ + "${IMAGE}" "${IMAGE}.sha256" "${DEB}" \ + deploy/SHA256SUMS deploy/SHA256SUMS.sigstore.json \ + imager/os_list.json + + - name: Publish the apt repo and the Imager listing (gh-pages) + env: + APT_SIGNING_KEY: ${{ secrets.APT_SIGNING_KEY }} + run: | + set -euo pipefail + sudo apt-get update -qq && sudo apt-get install -y -qq apt-utils + echo "${APT_SIGNING_KEY}" | gpg --batch --import + APT_SIGNING_KEY_ID="$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/ { print $10; exit }')" + export APT_SIGNING_KEY_ID + + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git fetch --depth=1 origin gh-pages; then + git worktree add site FETCH_HEAD + git -C site checkout -B gh-pages + else + git worktree add --orphan -b gh-pages site + fi + + scripts/apt-repo.sh site/apt "${DEB}" + cp packaging/apt/syncphony-box-archive-keyring.asc site/apt/ + mkdir -p site/imager + cp imager/os_list.json site/imager/os_list.json + cp packaging/imager/icon.png site/imager/icon.png + touch site/.nojekyll + + git -C site add -A + git -C site commit -m "release ${GITHUB_REF_NAME}: apt repo and Imager listing" + git -C site push origin gh-pages diff --git a/.gitignore b/.gitignore index 4a44908..f3496fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ deploy/ .build/ # Built by ./build.sh into the pi-gen stage, never committed. -stage-syncphony/05-boxd/files/boxd +stage-syncphony/00-packages/files/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..83eb5b6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +Every release's notes come from its section here: pushing tag `vX.Y.Z` +publishes the `## [X.Y.Z]` section as the GitHub Release notes, and the +release fails if the section is missing ([docs/releasing.md](docs/releasing.md)). +Add to `## [Unreleased]` as you go; rename it when you tag. + +## [Unreleased] + +### Added +- **Releases you can flash from Raspberry Pi Imager.** A `v*` tag builds + the image, smoke-tests it and publishes a GitHub Release with the + `.img.xz`, its checksum, a cosign-signed `SHA256SUMS` and the notes + from this file. An Imager listing (`imager/os_list.json` on GitHub + Pages) puts "Syncphony Box" into Imager through App Options → Content + Repository, with Imager's Wi-Fi, SSH and hostname settings working. +- **Updates without reflashing.** The box's own software is now the + `syncphony-box` .deb, served from a signed apt repository on GitHub + Pages. Every night around 04:00 the box installs Debian security + updates and new box releases onto its read-only root, never while + music plays, and reboots only if something changed and the room is + idle. `auto_update=off` in `syncphony.txt` turns it off; + `sudo syncphony-box update` runs it by hand. +- `/v1/info` reports uptime and the last update (when, and whether it worked) for the + room's Screens list. +- The Wi-Fi setup hotspot for a box with no network. +- HDMI-CEC: the TV wakes and switches when music starts; the remote's + keys drive the box; optional standby when the room is idle. +- Watchdogs and self-healing: hardware watchdog, kiosk watchdog, + thermals in the journal, a nightly kiosk reload. +- Read-only root and the `/data` partition: safe to unplug at any time. +- boxd: the box bridge, the setup and offline screens, pairing that + survives reboots and power cuts. +- Audio output selection (`audio=`) on PipeWire. +- `syncphony.txt` boot config file and the cage + Chromium kiosk session. + +### Changed +- The image is smaller: documentation, man pages and translations are + stripped (copyright files stay). + +## [0.1.0] + +### Added +- The pi-gen build for the syncphony-box image. diff --git a/Makefile b/Makefile index 270dc05..afbb93d 100644 --- a/Makefile +++ b/Makefile @@ -28,5 +28,5 @@ reset: .build/boxd clean: rm -rf deploy .build - rm -f stage-syncphony/05-boxd/files/boxd + rm -rf stage-syncphony/00-packages/files -docker rm -v pigen_work >/dev/null 2>&1 || true diff --git a/README.md b/README.md index 580aa13..a86115e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,66 @@ plug the Pi into the TV, and it boots straight into the room. The box is **client only**: it runs no Syncphony server. Point it at yours. +## Install + +You need a Raspberry Pi 4 or 5, a 16 GB+ SD card, [Raspberry Pi +Imager](https://www.raspberrypi.com/software/) 1.9 or newer, and the +address of your Syncphony server. + +1. **Add the Syncphony Box listing to Imager.** Open Imager → **App + Options** → **Content Repository** → **Use custom URL**, and paste: + + ``` + https://madeofpendletonwool.github.io/syncphony-box/imager/os_list.json + ``` + + (Or start Imager with it from a terminal: + `rpi-imager --repo https://madeofpendletonwool.github.io/syncphony-box/imager/os_list.json`.) +2. **Choose** your Pi model, then **Syncphony Box** as the operating + system, and your SD card. +3. **Customize** when Imager offers it: set a username and password, your + **Wi-Fi** (skip it if the box will be on Ethernet), and optionally the + hostname, your timezone (the nightly update runs at 04:00 local) and + SSH. The image has no default login. Without Wi-Fi settings or a + cable, the box starts its own setup hotspot instead (see below). +4. **Write**, put the card in the Pi, plug the Pi into the TV with the + HDMI port next to the USB-C power, and power it on. +5. **Point it at your server.** The TV shows a QR code: scan it with a + phone on the same network and type your server's address. (Or, before + step 4, open `syncphony.txt` on the card's `bootfs` drive and set + `server_url=` — see [Configuration](#configuration-syncphonytxt).) +6. **Pair from your phone.** The TV shows a pairing code; in your room on + Syncphony, open the Screens list and add it. Done: the box plays the + room from then on, through reboots, power cuts and updates. + +Updates arrive by themselves overnight ([Updates](#updates)). + +### Without the listing + +Every [release](https://github.com/madeofpendletonwool/syncphony-box/releases) +has the image (`syncphony-box--arm64.img.xz`). In Imager pick +**Use custom** and choose the downloaded file; customization works the +same way. + +### Checking a download + +Each release has a `SHA256SUMS` file signed with +[cosign](https://docs.sigstore.dev/cosign/system_config/installation/) +by the release workflow itself (keyless, through GitHub's OIDC, so there +is no key to leak). With the release's files in one directory: + +```sh +cosign verify-blob SHA256SUMS \ + --bundle SHA256SUMS.sigstore.json \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github\.com/madeofpendletonwool/syncphony-box/\.github/workflows/build\.yml@refs/tags/v' +sha256sum --check --ignore-missing SHA256SUMS +``` + +`Verified OK` and `OK` mean the image is exactly what CI built from the +tagged source. (Imager checks the image against the listing's SHA-256 on +its own while writing.) + ## Supported hardware - Raspberry Pi 4 or Pi 5, 2 GB RAM or more @@ -36,6 +96,9 @@ The result lands in `deploy/`: - `syncphony-box--arm64.img.xz` — the image (`` from `git describe`) - `syncphony-box--arm64.img.xz.sha256` — its checksum +- `syncphony-box__arm64.deb`, the box's software as a + package. The image installs it, and boxes update to newer ones from the + apt repo. `make clean` removes build output and the leftover pi-gen build container. @@ -54,7 +117,8 @@ loop-mounts the built image and checks packages, enabled units, the locked first user, the first-boot seed files, the kiosk session, the boot config file, the audio stack, boxd, and the read-only-root layout: three partitions, the overlay wired into the initramfs, and every persistent -path on /data), and uploads the image as a workflow artifact. +path on /data), and uploads the image as a workflow artifact. Pushing a +`v*` tag also releases it; see [docs/releasing.md](docs/releasing.md). ## boxd @@ -194,16 +258,48 @@ The power-pull test procedure (50 pulls: during boot, during playback, seconds after pairing) is [docs/testing.md](docs/testing.md). **Maintaining the box.** Runtime writes under `/` are discarded by -design, so package updates go to the real root through a chroot (the -overlayroot-chroot role in this image): +design, so package changes go to the real root through a chroot (the +overlayroot-chroot role in this image). The nightly update does this by +itself ([Updates](#updates)); for anything else: ```sh sudo syncphony-box chroot # shell on the real root, read-write -# (or: sudo syncphony-box chroot apt full-upgrade) +# (or: sudo syncphony-box chroot apt install ) ``` -Reboot afterwards. Changes to the pairing, config and anything else on -`/data` need no chroot — that partition is writable at runtime. +`/data` and a writable `/boot/firmware` come along into the chroot, so a +kernel update lands too. Reboot afterwards. Changes to the pairing, +config and anything else on `/data` need no chroot, because that partition +is writable at runtime. + +## Updates + +The box keeps itself current with no reflashing +([ADR 0012](docs/adr/0012-box-updates.md)). The web app needs nothing: the +Syncphony server serves it. What updates is the OS and the box's own +software, which is the `syncphony-box` package, served from a signed apt +repository on this repo's GitHub Pages. + +- **Every night around 04:00** (box-local time) `syncphony-update.timer` + runs `boxd update`: Debian and Raspberry Pi security updates plus the + newest `syncphony-box`, installed onto the real root filesystem through + `syncphony-box chroot`. If the room is playing, it skips the night. +- **It reboots only if something changed**, and only once the room is + idle. The pairing lives on `/data` and is untouched. +- `auto_update=off` in `syncphony.txt` turns the nightly run off. +- `sudo syncphony-box update` runs it now (even with `auto_update=off`); + add `--no-reboot` to reboot on your own schedule. +- `syncphony-box version` prints the installed version; `/v1/info` reports + it with the last update's time and result, which the room's Screens list + shows. +- The result of the last run is in `/data/syncphony-box/update.json`, and + the details are in `journalctl -u syncphony-update`. + +**If an update fails**, the box keeps running what it had, the Screens +list says "update failed", and the next night tries again. **If a box no +longer boots after an update**, reflash the card with the latest release +and pair it again (step 6 above). There are no A/B partitions to fall +back on. ## Self-healing: watchdogs @@ -293,6 +389,7 @@ the box from booting; bad values are logged and fall back to defaults. | `cec` | `on`, `off` | `on` | switch the TV's input when music starts (HDMI-CEC; the remote's keys drive the box too — [docs/hardware.md](docs/hardware.md) for the TV matrix) | | `cec_standby` | `on`, `off` | `off` | put the TV in standby once the room has been idle (only ever a TV the box switched on itself) | | `cec_standby_minutes` | `1`–`240` | `30` | how many idle minutes before that standby | +| `auto_update` | `on`, `off` | `on` | install updates every night around 04:00 ([Updates](#updates)) | On every boot, `syncphony-box-config.service` runs `boxd config apply`: it parses the file into `config.env` (kept on `/data`; `/etc/syncphony-box/config.env` @@ -378,42 +475,47 @@ has its own page: [docs/testing.md](docs/testing.md). - A 12-hour playback soak with the visualizer on, on a 2 GB Pi 4 (the procedure is in docs/testing.md): memory plateaus, and the 05:00 nightly reload happens only while idle. +- Updates ([docs/testing.md](docs/testing.md), "The overnight update"): + flash release N, pair, publish N+1, leave the box on overnight. In the + morning `syncphony-box version` says N+1, the box is still paired and + playing, `findmnt /` is still the overlay, and `/v1/info` has an + `update` with `result: updated`. Playing music across 04:00 skips + that night. - An hour of real playback as the room's speaker — Navidrome (MP3 and FLAC) and Spotify (Ogg): no gaps between songs, no drift, no clipped first notes. -## Flashing - -1. Write the `.img.xz` to an SD card with Raspberry Pi Imager. -2. In the OS customization settings, set at minimum a username and password - (Wi-Fi, SSH and hostname as needed — without Wi-Fi settings or a cable, - the box offers its setup hotspot instead). The first user in the image ships with - a **locked password** — there is no default login; the Imager settings are - applied on first boot by cloud-init. -3. Boot the Pi. Without Imager customization the image behaves like stock - Raspberry Pi OS Lite and asks for a username and password on the console. - ## Repo layout ``` pi-gen/ pi-gen submodule (pinned, never edited) -stage-syncphony/ custom pi-gen stage (packages, kiosk session, - read-only root, CEC, Wi-Fi setup, ...) +packaging/root/ every file of the syncphony-box .deb, laid out as + it installs (units, kiosk wrapper, scripts, ...) +packaging/debian/ the .deb's control template and postinst +packaging/apt/ the updater's apt source and the repo's public key +packaging/imager/ the Imager listing's icon +stage-syncphony/ custom pi-gen stage: installs the .deb, then the + image-only changes (cmdline, fstab, /data links, ...) boxd/ the box's Go helper daemon (bridge, config, screens, watchdogs, CEC, Wi-Fi setup hotspot) config pi-gen build configuration -build.sh wraps pi-gen's build-docker.sh (builds boxd first, - then appends the /data partition after export) +build.sh wraps pi-gen's build-docker.sh (builds boxd and the + .deb first, then appends the /data partition) Makefile make image / make test / make lint / make clean scripts/add-data-partition.sh the /data partition post-export step scripts/smoke-test.sh no-hardware image checks (used by CI) +scripts/build-deb.sh builds the syncphony-box .deb +scripts/apt-repo.sh, imager-os-list.py, changelog-section.sh + the release job's pieces (docs/releasing.md) +CHANGELOG.md release notes, one section per version tests/ bats tests pinning the syncphony.txt and /data behavior (used by CI) docs/adr/ architecture decision records docs/persistent-paths.md the list of everything that survives a power cut docs/hardware.md tested boards and the TV CEC compatibility matrix docs/testing.md the hardware test procedures (power pulls & co) +docs/releasing.md cutting a release; the signing keys ``` ## License diff --git a/boxd/cmd/boxd/main.go b/boxd/cmd/boxd/main.go index 61acbde..aa1d76f 100644 --- a/boxd/cmd/boxd/main.go +++ b/boxd/cmd/boxd/main.go @@ -14,6 +14,7 @@ import ( "github.com/madeofpendletonwool/syncphony-box/boxd/internal/config" "github.com/madeofpendletonwool/syncphony-box/boxd/internal/daemon" "github.com/madeofpendletonwool/syncphony-box/boxd/internal/profile" + "github.com/madeofpendletonwool/syncphony-box/boxd/internal/update" ) // version is the box software's version, stamped in by the build @@ -36,6 +37,8 @@ func main() { urlCmd() case "profile": profileCmd(os.Args[2:]) + case "update": + updateCmd(os.Args[2:]) case "version", "--version", "-v": fmt.Println(version) case "help", "--help", "-h": @@ -62,6 +65,9 @@ usage: boxd profile prep [DIR] clear Chromium's unclean-exit state before a start boxd profile reset [DIR] wipe the profile; the box re-pairs from scratch boxd profile path print the profile directory (SB_PROFILE overrides) + boxd update [--manual] [--no-reboot] + install Debian and box updates on the real root; + reboot into them once the room is idle boxd --version print the box software version `) } @@ -153,3 +159,33 @@ func profileCmd(args []string) { os.Exit(1) } } + +// updateCmd is the nightly update (MAD-810, ADR 0012), run by +// syncphony-update.timer — or by hand through `sudo syncphony-box update` +// (--manual: runs even with auto_update=off or music playing). +func updateCmd(args []string) { + fs := flag.NewFlagSet("update", flag.ExitOnError) + manual := fs.Bool("manual", false, "run even with auto_update=off or while the room plays") + noReboot := fs.Bool("no-reboot", false, "install, but don't reboot afterwards") + fs.Parse(args) + + unlock, err := update.Lock() + if err != nil { + log.Fatalf("update: %v", err) + } + defer unlock() + cfg := config.ParseFile(config.PathsFromEnv().Txt) + st, err := update.Run(update.RealEnv(log.Default()), update.Options{ + Manual: *manual, + NoReboot: *noReboot, + AutoUpdate: cfg.AutoUpdate, + }) + switch { + case err == update.Skipped: + case err != nil: + unlock() + os.Exit(1) + case *manual: + fmt.Printf("syncphony-box update: %s (syncphony-box %s)\n", st.Result, st.Version) + } +} diff --git a/boxd/internal/cec/uinput.go b/boxd/internal/cec/uinput.go index 4e9b78e..23db478 100644 --- a/boxd/internal/cec/uinput.go +++ b/boxd/internal/cec/uinput.go @@ -12,7 +12,7 @@ import ( // structs. cage (like any Wayland compositor) picks the device up through // libinput and Chromium sees ordinary key events — no shell, no root // helper, just /dev/uinput (which the udev rule in -// stage-syncphony/08-cec grants boxd's group). +// packaging/root/usr/lib/udev/rules.d grants boxd's group). // From linux/input.h and linux/uinput.h. const ( diff --git a/boxd/internal/config/config.go b/boxd/internal/config/config.go index fe5dd67..018ac3f 100644 --- a/boxd/internal/config/config.go +++ b/boxd/internal/config/config.go @@ -52,6 +52,7 @@ type Config struct { CEC string CECStandby string // cec_standby: on puts the TV in standby when the room goes quiet CECStandbyMinutes int // cec_standby_minutes: how long quiet before that standby + AutoUpdate string // auto_update: on lets the nightly update run (MAD-810) Warnings []string // ProfileDir is not a syncphony.txt key: it is always the default (or @@ -65,6 +66,7 @@ func Defaults() Config { return Config{ Audio: "hdmi", Rotate: "0", CEC: "on", CECStandby: "off", CECStandbyMinutes: 30, + AutoUpdate: "on", ProfileDir: DefaultProfileDir, } } @@ -191,6 +193,15 @@ func (c *Config) apply(key, value string) { c.CECStandbyMinutes = n } } + case "auto_update": + value = strings.ToLower(value) + switch value { + case "": + case "on", "off": + c.AutoUpdate = value + default: + c.warn("invalid auto_update '%s' (want on or off); using %s", value, c.AutoUpdate) + } default: c.warn("ignoring unknown key '%s'", key) } @@ -248,6 +259,7 @@ func (c Config) EnvContent() string { fmt.Fprintf(&b, "CEC=%s\n", shellQuote(c.CEC)) fmt.Fprintf(&b, "CEC_STANDBY=%s\n", shellQuote(c.CECStandby)) fmt.Fprintf(&b, "CEC_STANDBY_MINUTES=%d\n", c.CECStandbyMinutes) + fmt.Fprintf(&b, "AUTO_UPDATE=%s\n", shellQuote(c.AutoUpdate)) fmt.Fprintf(&b, "PROFILE_DIR=%s\n", shellQuote(c.ProfileDir)) return b.String() } diff --git a/boxd/internal/config/config_test.go b/boxd/internal/config/config_test.go index b5d6b4f..517483d 100644 --- a/boxd/internal/config/config_test.go +++ b/boxd/internal/config/config_test.go @@ -26,7 +26,7 @@ func TestMissingFileYieldsDefaults(t *testing.T) { c := ParseFile(filepath.Join(t.TempDir(), "nope.txt")) if c.ServerURL != "" || c.Name != "" || c.Hostname != "" || c.Audio != "hdmi" || c.Resolution != "" || c.Rotate != "0" || c.CEC != "on" || - c.CECStandby != "off" || c.CECStandbyMinutes != 30 { + c.CECStandby != "off" || c.CECStandbyMinutes != 30 || c.AutoUpdate != "on" { t.Fatalf("got %+v", c) } } @@ -39,10 +39,10 @@ func TestBOMAndCRLF(t *testing.T) { } func TestAllKeysTogether(t *testing.T) { - c := parse(t, "server_url=https://s.example\nname=Den TV\naudio=analog\nresolution=1920x1080@60\nrotate=180\ncec=off\ncec_standby=on\ncec_standby_minutes=15\n") + c := parse(t, "server_url=https://s.example\nname=Den TV\naudio=analog\nresolution=1920x1080@60\nrotate=180\ncec=off\ncec_standby=on\ncec_standby_minutes=15\nauto_update=OFF\n") if c.ServerURL != "https://s.example" || c.Name != "Den TV" || c.Hostname != "den-tv" || c.Audio != "analog" || c.Resolution != "1920x1080@60" || c.Rotate != "180" || c.CEC != "off" || - c.CECStandby != "on" || c.CECStandbyMinutes != 15 { + c.CECStandby != "on" || c.CECStandbyMinutes != 15 || c.AutoUpdate != "off" { t.Fatalf("got %+v", c) } } @@ -54,11 +54,12 @@ func TestUnknownKeysAndLinesWarn(t *testing.T) { } func TestInvalidValuesFallBack(t *testing.T) { - c := parse(t, "server_url=ftp://nope.example\naudio=spdif\nrotate=45\ncec=yes\nresolution=1080p\ncec_standby=maybe\ncec_standby_minutes=0\n") + c := parse(t, "server_url=ftp://nope.example\naudio=spdif\nrotate=45\ncec=yes\nresolution=1080p\ncec_standby=maybe\ncec_standby_minutes=0\nauto_update=sometimes\n") if c.ServerURL != "" || c.Audio != "hdmi" || c.Rotate != "0" || c.CEC != "on" || c.Resolution != "" || - c.CECStandby != "off" || c.CECStandbyMinutes != 30 { + c.CECStandby != "off" || c.CECStandbyMinutes != 30 || c.AutoUpdate != "on" { t.Fatalf("got %+v", c) } + wantWarning(t, c, "invalid auto_update 'sometimes'") wantWarning(t, c, "invalid server_url 'ftp://nope.example'") wantWarning(t, c, "invalid audio 'spdif'") wantWarning(t, c, "invalid rotate '45'") diff --git a/boxd/internal/daemon/bridge.go b/boxd/internal/daemon/bridge.go index 9bada07..49c4545 100644 --- a/boxd/internal/daemon/bridge.go +++ b/boxd/internal/daemon/bridge.go @@ -4,8 +4,12 @@ import ( "encoding/json" "net" "net/http" + "os" + "strconv" "strings" "time" + + "github.com/madeofpendletonwool/syncphony-box/boxd/internal/update" ) // bridgeMux serves everything Chromium-facing on loopback: the /v1 box @@ -132,6 +136,19 @@ func (d *Daemon) handleInfo(w http.ResponseWriter, r *http.Request) { info[k] = v } } + // Uptime for the Screens list ("up 3 days"); absent off Linux. + if up, ok := uptimeSeconds(); ok { + info["uptimeSeconds"] = up + } + // The last nightly update (MAD-810), for the Screens list's "updated + // 3 h ago" / "update failed". Absent until the box has run one. + if st, ok := update.ReadState(update.StatePath()); ok { + u := map[string]any{"at": st.At.UTC().Format(time.RFC3339), "result": st.Result} + if st.Error != "" { + u["error"] = st.Error + } + info["update"] = u + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(info) } @@ -181,6 +198,16 @@ func (d *Daemon) handleEvents(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// playing reports whether the room is playing right now: the page's last +// event says so and the page is still alive (a fresh heartbeat — a dead +// page's last word doesn't count). The nightly update asks this through +// /status before it starts and before it reboots (MAD-810). +func (d *Daemon) playing() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.lastEvent == "playing" && time.Since(d.lastBeat) < heartbeatWindow +} + func (d *Daemon) handleHeartbeat(w http.ResponseWriter, r *http.Request) { d.mu.Lock() d.lastBeat = time.Now() @@ -209,3 +236,24 @@ func (d *Daemon) handleReload(w http.ResponseWriter, r *http.Request) { d.restartKiosk("reload (remote care)", false) }() } + +// uptimeSeconds is the system's uptime from /proc/uptime, whole seconds. +func uptimeSeconds() (int64, bool) { + data, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0, false + } + return parseUptime(string(data)) +} + +func parseUptime(s string) (int64, bool) { + f := strings.Fields(s) + if len(f) == 0 { + return 0, false + } + v, err := strconv.ParseFloat(f[0], 64) + if err != nil || v < 0 { + return 0, false + } + return int64(v), true +} diff --git a/boxd/internal/daemon/daemon_test.go b/boxd/internal/daemon/daemon_test.go index ec2d47c..49ab67c 100644 --- a/boxd/internal/daemon/daemon_test.go +++ b/boxd/internal/daemon/daemon_test.go @@ -33,6 +33,9 @@ func newTestDaemon(t *testing.T, txtContent string) (*Daemon, *recorder) { // boxd's /data state (the hotspot's "been online" marker) in the // test's own directory. t.Setenv("SB_BOXD_DIR", dir) + // The nightly update's last result (MAD-810): none unless a test + // writes one. + t.Setenv("SB_UPDATE_STATE", filepath.Join(dir, "update.json")) d := New("test-version", log.New(log.Writer(), "", 0), rec.run) return d, rec } diff --git a/boxd/internal/daemon/screens.go b/boxd/internal/daemon/screens.go index 36d43df..bbc8104 100644 --- a/boxd/internal/daemon/screens.go +++ b/boxd/internal/daemon/screens.go @@ -16,6 +16,7 @@ func (d *Daemon) handleStatus(w http.ResponseWriter, r *http.Request) { th := d.therm d.mu.Unlock() status := map[string]any{ + "playing": d.playing(), "configured": server != "", "server": server, "reachable": d.serverReachable(), diff --git a/boxd/internal/daemon/update_info_test.go b/boxd/internal/daemon/update_info_test.go new file mode 100644 index 0000000..0619661 --- /dev/null +++ b/boxd/internal/daemon/update_info_test.go @@ -0,0 +1,81 @@ +package daemon + +import ( + "encoding/json" + "os" + "testing" + "time" +) + +// The nightly update (MAD-810) asks /status whether the room is playing +// before it starts and before it reboots: the last event must say so and +// the page must still be beating. +func TestStatusPlayingNeedsALivePage(t *testing.T) { + d, _ := newTestDaemon(t, "server_url=https://s.example\n") + mux := d.bridgeMux() + playing := func() bool { + t.Helper() + var st struct { + Playing bool `json:"playing"` + } + if err := json.Unmarshal(get(t, mux, "/status").Body.Bytes(), &st); err != nil { + t.Fatal(err) + } + return st.Playing + } + if playing() { + t.Fatal("a fresh box reports playing") + } + post(t, mux, "/v1/heartbeat", "") + post(t, mux, "/v1/events", `{"type":"playing"}`) + if !playing() { + t.Fatal("playing with a live page not reported") + } + d.mu.Lock() + d.lastBeat = time.Now().Add(-2 * heartbeatWindow) + d.mu.Unlock() + if playing() { + t.Fatal("a dead page's last event still counts as playing") + } + post(t, mux, "/v1/heartbeat", "") + post(t, mux, "/v1/events", `{"type":"paused"}`) + if playing() { + t.Fatal("paused reported as playing") + } +} + +// /v1/info carries the last update for the Screens list (MAD-811), and +// leaves the field out until the box has run one. +func TestInfoReportsTheLastUpdate(t *testing.T) { + d, _ := newTestDaemon(t, "server_url=https://s.example\n") + info := func() map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(get(t, d.bridgeMux(), "/v1/info").Body.Bytes(), &m); err != nil { + t.Fatal(err) + } + return m + } + if _, ok := info()["update"]; ok { + t.Fatal("update reported before any run") + } + os.WriteFile(os.Getenv("SB_UPDATE_STATE"), []byte(`{"at":"2026-10-16T04:03:00Z","result":"failed","error":"apt-get update: exit status 100","version":"1.3.0"}`), 0o644) + u, ok := info()["update"].(map[string]any) + if !ok { + t.Fatalf("no update in %v", info()) + } + if u["at"] != "2026-10-16T04:03:00Z" || u["result"] != "failed" || u["error"] != "apt-get update: exit status 100" { + t.Fatalf("update %v", u) + } +} + +func TestParseUptime(t *testing.T) { + if v, ok := parseUptime("354121.47 1371921.18\n"); !ok || v != 354121 { + t.Fatalf("got %d %v", v, ok) + } + for _, bad := range []string{"", "nope 1", "-3 1"} { + if _, ok := parseUptime(bad); ok { + t.Fatalf("%q parsed", bad) + } + } +} diff --git a/boxd/internal/daemon/watchdog.go b/boxd/internal/daemon/watchdog.go index 5ce041f..d2b26f5 100644 --- a/boxd/internal/daemon/watchdog.go +++ b/boxd/internal/daemon/watchdog.go @@ -10,7 +10,7 @@ import ( // independent watchdogs: // // - The kernel's hardware watchdog, armed by systemd's -// RuntimeWatchdogSec=15s (stage-syncphony/07-watchdog), catches what +// RuntimeWatchdogSec=15s (the package's system.conf.d drop-in), catches what // no userspace loop can: a kernel or PID-1 hang hard-resets the Pi // within 15 s. // - The loop here, in boxd, catches a wedged page: the /tv heartbeat diff --git a/boxd/internal/update/update.go b/boxd/internal/update/update.go new file mode 100644 index 0000000..c59aacc --- /dev/null +++ b/boxd/internal/update/update.go @@ -0,0 +1,495 @@ +// Package update is the box's nightly update (MAD-810, ADR 0012): +// Debian security updates and new versions of the syncphony-box package +// (boxd, the kiosk, the units — everything that isn't the web app, which +// the Syncphony server serves itself), installed on the REAL root +// filesystem through `syncphony-box chroot` — runtime writes under / land +// in the overlay's tmpfs and would vanish at the next boot (ADR 0007). +// +// The policy, in order: +// +// - auto_update=off in syncphony.txt skips the nightly run (a manual +// `sudo syncphony-box update` still runs). +// - While the room is playing (the bridge's last event, with a live +// heartbeat), the nightly run skips: tomorrow is another night. +// - apt-get update, then apt-get upgrade --with-new-pkgs (a kernel or a +// new syncphony-box dependency arrives as a new package). +// - The result — updated, current or failed, when, the installed +// syncphony-box version — goes to /data for /v1/info (the room's +// Screens list shows it, MAD-811). +// - Only if something changed, and only once the room is idle, the box +// reboots into it. A failed update never reboots: the old system is +// still the running one, and the next night retries. +package update + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "syscall" + "time" +) + +const ( + // DefaultStatePath is the last run's result, on /data so it survives + // the reboot the run itself causes. SB_UPDATE_STATE overrides it. + DefaultStatePath = "/data/syncphony-box/update.json" + + // The bridge's local status (served by boxd on loopback): "playing". + statusURL = "http://127.0.0.1:8099/status" + + // minFreeBytes on the root partition before an upgrade starts. The + // root never grows (ADR 0007); pi-gen leaves 20% + 200 MB of + // headroom, and a kernel upgrade unpacked half-way is worse than one + // not started. + minFreeBytes = 150 << 20 + + // The idle wait before the reboot: poll every minute, give up after + // IdleWait (the next boot picks the update up anyway). + idlePoll = time.Minute + DefaultIdleWait = 4 * time.Hour + + // The package this repo builds; its version is the box's version. + PackageName = "syncphony-box" + + // SourceList is the box's own apt repository, shipped by the package. + // It is handed to apt only here (as Dir::Etc::SourceList — the stock + // sources.list is empty on Pi OS; Debian and Raspberry Pi live in + // sources.list.d and stay), never installed into /etc/apt: the image + // build's own apt runs must not see it (ADR 0012). + SourceList = "/usr/share/syncphony-box/apt/syncphony-box.list" + + lockPath = "/run/syncphony-update.lock" +) + +// Results recorded in the state file. +const ( + ResultUpdated = "updated" // something was installed; the box reboots into it + ResultCurrent = "current" // nothing to do + ResultFailed = "failed" // see Error; the box keeps running the old system +) + +// State is the last update run, as /v1/info reports it. +type State struct { + At time.Time `json:"at"` + Result string `json:"result"` + Error string `json:"error,omitempty"` + Version string `json:"version,omitempty"` // the installed syncphony-box package after the run + Changed []string `json:"changed,omitempty"` // "name old -> new" + Manual bool `json:"manual,omitempty"` +} + +// StatePath is where the state file lives (the SB_UPDATE_STATE hook for +// dev and tests, like the other SB_* paths). +func StatePath() string { + if p := os.Getenv("SB_UPDATE_STATE"); p != "" { + return p + } + return DefaultStatePath +} + +// ReadState reads the last run's result; false when there is none (a +// box that has never updated) or it can't be read. +func ReadState(path string) (State, bool) { + data, err := os.ReadFile(path) + if err != nil { + return State{}, false + } + var s State + if err := json.Unmarshal(data, &s); err != nil || s.At.IsZero() { + return State{}, false + } + return s, true +} + +// writeState replaces the state file atomically and durably: the reboot +// that follows a successful run must not lose it. +func writeState(path string, s State) error { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + if _, err := f.Write(append(data, '\n')); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// Env is everything Run touches outside itself — real on a box, fakes in +// the tests. +type Env struct { + Log *log.Logger + // Run executes a command, returning its stdout and stderr separately + // (dpkg-query's stdout is parsed; apt's output is logged). + Run func(name string, args ...string) (stdout, stderr string, err error) + // Playing reports whether the room is playing right now. + Playing func() (bool, error) + // OverlayActive reports whether / is the read-only overlay (then the + // real root is reached through `syncphony-box chroot`). + OverlayActive func() bool + // HasSourceList reports whether the package shipped the repo (a dev + // build without the signing key doesn't); then only Debian and + // Raspberry Pi updates install. + HasSourceList func() bool + // FreeBytes is the free space on the real root filesystem. + FreeBytes func(overlay bool) (uint64, error) + Sleep func(time.Duration) + Now func() time.Time + StatePath string +} + +// Options for one run. +type Options struct { + Manual bool // sudo syncphony-box update: ignores auto_update=off and playback + NoReboot bool // install, but leave the reboot to the person who asked + AutoUpdate string // auto_update= from syncphony.txt ("on"/"off") + IdleWait time.Duration // how long to wait for an idle room before rebooting +} + +// RealEnv is the box: real commands, the bridge on loopback, statfs. +func RealEnv(logger *log.Logger) Env { + return Env{ + Log: logger, + Run: realRun, + Playing: realPlaying, + OverlayActive: realOverlayActive, + HasSourceList: func() bool { _, err := os.Stat(SourceList); return err == nil }, + FreeBytes: realFreeBytes, + Sleep: time.Sleep, + Now: time.Now, + StatePath: StatePath(), + } +} + +// Lock takes the run lock so the timer and a manual run never overlap. +// The returned func releases it. +func Lock() (func(), error) { + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + return nil, errors.New("another update is already running") + } + return func() { f.Close() }, nil +} + +// Skipped is returned (with no state written) when the policy says not +// tonight: auto_update=off, or the room is playing. +var Skipped = errors.New("update skipped") + +// Run performs one update. It returns the recorded state; Skipped when +// nothing was attempted; and a non-nil error for a failed run (the state +// records it too). +func Run(env Env, opt Options) (State, error) { + lg := env.Log + if !opt.Manual && opt.AutoUpdate == "off" { + lg.Printf("update: auto_update=off in syncphony.txt; skipping (sudo syncphony-box update still works)") + return State{}, Skipped + } + if playing, err := env.Playing(); err != nil { + // boxd down means no page and no music; nothing to protect. + lg.Printf("update: cannot ask boxd whether the room is playing (%v); treating it as idle", err) + } else if playing { + if !opt.Manual { + lg.Printf("update: the room is playing; skipping tonight's update") + return State{}, Skipped + } + lg.Printf("update: the room is playing; updating anyway (asked by hand), the reboot waits for a pause") + } + + overlay := env.OverlayActive() + var prefix []string + if overlay { + prefix = []string{"/usr/bin/syncphony-box", "chroot"} + } + onRoot := func(name string, args ...string) (string, string, error) { + full := append(append(append([]string{}, prefix...), name), args...) + return env.Run(full[0], full[1:]...) + } + + state := State{At: env.Now().UTC(), Manual: opt.Manual} + fail := func(format string, args ...any) (State, error) { + state.Result = ResultFailed + state.Error = fmt.Sprintf(format, args...) + lg.Printf("update: failed: %s", state.Error) + if err := writeState(env.StatePath, state); err != nil { + lg.Printf("update: cannot record the result: %v", err) + } + return state, errors.New(state.Error) + } + + if free, err := env.FreeBytes(overlay); err == nil && free < minFreeBytes { + return fail("not enough space on the root partition (%d MB free, need %d MB)", free>>20, minFreeBytes>>20) + } + + before, err := installed(onRoot) + if err != nil { + return fail("cannot list installed packages: %v", err) + } + state.Version = before[PackageName] + + apt := []string{"DEBIAN_FRONTEND=noninteractive", "apt-get", "-q", "-y", + "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"} + if env.HasSourceList != nil && env.HasSourceList() { + apt = append(apt, "-o", "Dir::Etc::SourceList="+SourceList) + } else { + lg.Printf("update: no %s (a build without the repo key): Debian and Raspberry Pi updates only", SourceList) + } + lg.Printf("update: apt-get update") + // --error-on=any: a repo that can't be fetched or whose signature + // doesn't verify is only a warning to plain apt-get update (it keeps + // the old lists). Here it's a failure — the Screens list should say + // "update failed", not "current". + if out, errOut, err := onRoot("env", append(apt, "--error-on=any", "update")...); err != nil { + logOutput(lg, out, errOut) + return fail("apt-get update: %v: %s", err, tail(errOut, out)) + } + lg.Printf("update: apt-get upgrade") + out, errOut, upErr := onRoot("env", append(apt, "--with-new-pkgs", "upgrade")...) + logOutput(lg, out, errOut) + if _, _, err := onRoot("apt-get", "clean"); err != nil { + lg.Printf("update: apt-get clean: %v", err) + } + + after, err := installed(onRoot) + if err != nil { + return fail("cannot list installed packages after the upgrade: %v", err) + } + state.Version = after[PackageName] + state.Changed = diff(before, after) + if upErr != nil { + // Packages may be half-configured; the running system is still + // the old one (the overlay's lower layer changed underneath, but + // nothing reloads it until a reboot). Don't reboot into it: the + // next night retries, and docs/releasing.md says what to do if it + // keeps failing (reflash, re-pair). + return fail("apt-get upgrade: %v: %s", upErr, tail(errOut, out)) + } + + if len(state.Changed) == 0 { + state.Result = ResultCurrent + lg.Printf("update: already current (%s %s)", PackageName, orNone(state.Version)) + } else { + state.Result = ResultUpdated + lg.Printf("update: %d package(s) changed: %s", len(state.Changed), strings.Join(state.Changed, ", ")) + } + if err := writeState(env.StatePath, state); err != nil { + lg.Printf("update: cannot record the result: %v", err) + } + + if state.Result == ResultUpdated { + if opt.NoReboot { + lg.Printf("update: reboot to finish (--no-reboot)") + } else { + rebootWhenIdle(env, opt) + } + } + return state, nil +} + +// rebootWhenIdle reboots as soon as the room isn't playing, polling for +// up to opt.IdleWait. If the party outlasts it, the update simply lands +// at the next boot. +func rebootWhenIdle(env Env, opt Options) { + wait := opt.IdleWait + if wait == 0 { + wait = DefaultIdleWait + } + for waited := time.Duration(0); ; waited += idlePoll { + playing, err := env.Playing() + if err != nil || !playing { + env.Log.Printf("update: rebooting into the update") + if _, errOut, err := env.Run("systemctl", "reboot"); err != nil { + env.Log.Printf("update: reboot failed: %v %s", err, errOut) + } + return + } + if waited >= wait { + env.Log.Printf("update: the room kept playing for %s; the update applies at the next boot", wait) + return + } + env.Sleep(idlePoll) + } +} + +// installed lists the installed packages and their versions on the real +// root. +func installed(onRoot func(string, ...string) (string, string, error)) (map[string]string, error) { + out, errOut, err := onRoot("dpkg-query", "-W", "-f=${db:Status-Abbrev}|${Package}|${Version}\n") + if err != nil { + return nil, fmt.Errorf("%v: %s", err, strings.TrimSpace(errOut)) + } + return parseInstalled(out), nil +} + +func parseInstalled(out string) map[string]string { + pkgs := map[string]string{} + for _, line := range strings.Split(out, "\n") { + parts := strings.Split(strings.TrimSpace(line), "|") + if len(parts) != 3 || !strings.HasPrefix(parts[0], "ii") { + continue + } + pkgs[parts[1]] = parts[2] + } + return pkgs +} + +// diff lists what changed between two package snapshots, sorted. +func diff(before, after map[string]string) []string { + var changed []string + for name, v := range after { + switch old, ok := before[name]; { + case !ok: + changed = append(changed, fmt.Sprintf("%s (new) -> %s", name, v)) + case old != v: + changed = append(changed, fmt.Sprintf("%s %s -> %s", name, old, v)) + } + } + for name, old := range before { + if _, ok := after[name]; !ok { + changed = append(changed, fmt.Sprintf("%s %s -> (removed)", name, old)) + } + } + sort.Strings(changed) + return changed +} + +func logOutput(lg *log.Logger, out, errOut string) { + for _, s := range []string{out, errOut} { + for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") { + if line != "" { + lg.Printf("update: | %s", line) + } + } + } +} + +// tail is what the Screens list's "update failed" should say: apt's own +// E:/W: lines when there are any (they name the repo and the reason), +// else the last few lines of output. +func tail(outs ...string) string { + var lines, apt []string + for _, s := range outs { + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + lines = append(lines, line) + if strings.HasPrefix(line, "E: ") || strings.HasPrefix(line, "W: ") { + apt = append(apt, line) + } + } + } + if len(apt) > 0 { + lines = apt + } + if len(lines) > 3 { + lines = lines[len(lines)-3:] + } + msg := strings.Join(lines, " / ") + if len(msg) > 400 { + msg = msg[:400] + } + return msg +} + +func orNone(s string) string { + if s == "" { + return "not installed" + } + return s +} + +func realRun(name string, args ...string) (string, string, error) { + var out, errOut bytes.Buffer + cmd := exec.Command(name, args...) + cmd.Stdout, cmd.Stderr = &out, &errOut + err := cmd.Run() + return out.String(), errOut.String(), err +} + +var statusClient = &http.Client{Timeout: 5 * time.Second} + +func realPlaying() (bool, error) { + resp, err := statusClient.Get(statusURL) + if err != nil { + return false, err + } + defer resp.Body.Close() + var st struct { + Playing bool `json:"playing"` + } + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { + return false, err + } + return st.Playing, nil +} + +// realOverlayActive: the box's overlay is up — / is an overlay (the last +// /proc/mounts entry for / is what / is) AND the initramfs left the real +// root at /media/root-ro. An overlay root without that (a container) is +// somebody else's and is written directly. +func realOverlayActive() bool { + data, err := os.ReadFile("/proc/mounts") + if err != nil { + return false + } + return overlayFromMounts(string(data)) +} + +func overlayFromMounts(mounts string) bool { + fstype, realRoot := "", false + for _, line := range strings.Split(mounts, "\n") { + f := strings.Fields(line) + if len(f) < 3 { + continue + } + switch f[1] { + case "/": + fstype = f[2] + case "/media/root-ro": + realRoot = true + } + } + return fstype == "overlay" && realRoot +} + +// realFreeBytes: under the overlay the real root is the read-only mount +// the initramfs leaves at /media/root-ro (ADR 0007). +func realFreeBytes(overlay bool) (uint64, error) { + path := "/" + if overlay { + path = "/media/root-ro" + } + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return 0, err + } + return uint64(st.Bavail) * uint64(st.Bsize), nil +} diff --git a/boxd/internal/update/update_test.go b/boxd/internal/update/update_test.go new file mode 100644 index 0000000..3bd262d --- /dev/null +++ b/boxd/internal/update/update_test.go @@ -0,0 +1,287 @@ +package update + +import ( + "errors" + "io" + "log" + "path/filepath" + "strings" + "testing" + "time" +) + +// fakeBox scripts the commands an update runs: the dpkg-query snapshots +// (before, then after) and apt's success or failure. +type fakeBox struct { + t *testing.T + calls []string + snapshots []string // dpkg-query outputs, in order + failOn string // a substring of the command line that fails + playing []bool // Playing answers, in order (the last repeats) + overlay bool + free uint64 + slept time.Duration +} + +func (f *fakeBox) env(statePath string) Env { + return Env{ + Log: log.New(io.Discard, "", 0), + Run: func(name string, args ...string) (string, string, error) { + line := strings.Join(append([]string{name}, args...), " ") + f.calls = append(f.calls, line) + if f.failOn != "" && strings.Contains(line, f.failOn) { + return "", "E: Unable to fetch some archives\n", errors.New("exit status 100") + } + if strings.Contains(line, "dpkg-query") { + if len(f.snapshots) == 0 { + f.t.Fatalf("unexpected dpkg-query") + } + out := f.snapshots[0] + f.snapshots = f.snapshots[1:] + return out, "", nil + } + return "", "", nil + }, + Playing: func() (bool, error) { + if len(f.playing) == 0 { + return false, nil + } + p := f.playing[0] + if len(f.playing) > 1 { + f.playing = f.playing[1:] + } + return p, nil + }, + OverlayActive: func() bool { return f.overlay }, + HasSourceList: func() bool { return true }, + FreeBytes: func(bool) (uint64, error) { return f.free, nil }, + Sleep: func(d time.Duration) { f.slept += d }, + Now: func() time.Time { return time.Date(2026, 10, 16, 4, 3, 0, 0, time.UTC) }, + StatePath: statePath, + } +} + +func (f *fakeBox) ran(substr string) bool { + for _, c := range f.calls { + if strings.Contains(c, substr) { + return true + } + } + return false +} + +const ( + snapN = "ii |syncphony-box|1.2.0\nii |libc6|2.41-6\nrc |old-thing|1.0\n" + snapN1 = "ii |syncphony-box|1.3.0\nii |libc6|2.41-6+deb13u1\nii |linux-image-6.12.50|1\n" +) + +func newBox(t *testing.T, snaps ...string) *fakeBox { + return &fakeBox{t: t, snapshots: snaps, overlay: true, free: 1 << 30} +} + +func TestUpdateInstallsRecordsAndRebootsWhenIdle(t *testing.T) { + box := newBox(t, snapN, snapN1) + path := filepath.Join(t.TempDir(), "update.json") + st, err := Run(box.env(path), Options{AutoUpdate: "on"}) + if err != nil { + t.Fatal(err) + } + if st.Result != ResultUpdated || st.Version != "1.3.0" { + t.Fatalf("state %+v", st) + } + want := []string{ + "libc6 2.41-6 -> 2.41-6+deb13u1", + "linux-image-6.12.50 (new) -> 1", + "syncphony-box 1.2.0 -> 1.3.0", + } + if strings.Join(st.Changed, "|") != strings.Join(want, "|") { + t.Fatalf("changed %q", st.Changed) + } + // Everything ran on the real root, through the chroot. + for _, c := range box.calls { + if c != "systemctl reboot" && !strings.HasPrefix(c, "/usr/bin/syncphony-box chroot ") { + t.Errorf("not through the chroot: %s", c) + } + } + if !box.ran("--with-new-pkgs upgrade") || !box.ran("--force-confold") || + !box.ran("Dir::Etc::SourceList="+SourceList+" --error-on=any update") { + t.Errorf("upgrade not as expected: %q", box.calls) + } + if !box.ran("systemctl reboot") { + t.Errorf("no reboot after a changing update") + } + saved, ok := ReadState(path) + if !ok || saved.Result != ResultUpdated || saved.Version != "1.3.0" || !saved.At.Equal(st.At) { + t.Fatalf("saved %+v ok=%v", saved, ok) + } +} + +func TestNothingChangedMeansNoReboot(t *testing.T) { + box := newBox(t, snapN, snapN) + path := filepath.Join(t.TempDir(), "update.json") + st, err := Run(box.env(path), Options{AutoUpdate: "on"}) + if err != nil || st.Result != ResultCurrent || st.Version != "1.2.0" { + t.Fatalf("st %+v err %v", st, err) + } + if box.ran("reboot") { + t.Fatal("rebooted with nothing changed") + } + if saved, ok := ReadState(path); !ok || saved.Result != ResultCurrent { + t.Fatalf("saved %+v", saved) + } +} + +func TestPlayingSkipsTheNightlyRun(t *testing.T) { + box := newBox(t) + box.playing = []bool{true} + path := filepath.Join(t.TempDir(), "update.json") + if _, err := Run(box.env(path), Options{AutoUpdate: "on"}); err != Skipped { + t.Fatalf("err %v", err) + } + if len(box.calls) != 0 { + t.Fatalf("ran %q while playing", box.calls) + } + if _, ok := ReadState(path); ok { + t.Fatal("a skip must not overwrite the last result") + } +} + +func TestAutoUpdateOffSkipsUnlessManual(t *testing.T) { + box := newBox(t) + if _, err := Run(box.env(filepath.Join(t.TempDir(), "u.json")), Options{AutoUpdate: "off"}); err != Skipped { + t.Fatalf("err %v", err) + } + box = newBox(t, snapN, snapN) + st, err := Run(box.env(filepath.Join(t.TempDir(), "u.json")), Options{AutoUpdate: "off", Manual: true}) + if err != nil || st.Result != ResultCurrent || !st.Manual { + t.Fatalf("manual run: st %+v err %v", st, err) + } +} + +func TestRebootWaitsForTheRoomToGoIdle(t *testing.T) { + box := newBox(t, snapN, snapN1) + // Manual run while playing: update anyway, then wait out three more + // "playing" polls before the reboot. + box.playing = []bool{true, true, true, true, false} + _, err := Run(box.env(filepath.Join(t.TempDir(), "u.json")), Options{Manual: true, AutoUpdate: "on"}) + if err != nil { + t.Fatal(err) + } + if !box.ran("systemctl reboot") || box.slept != 3*idlePoll { + t.Fatalf("slept %s, calls %q", box.slept, box.calls) + } +} + +func TestRebootGivesUpAfterTheIdleWait(t *testing.T) { + box := newBox(t, snapN, snapN1) + box.playing = []bool{false, true} // idle at the start, then a party + _, err := Run(box.env(filepath.Join(t.TempDir(), "u.json")), Options{AutoUpdate: "on", IdleWait: 10 * time.Minute}) + if err != nil { + t.Fatal(err) + } + if box.ran("reboot") { + t.Fatal("rebooted during playback") + } + if box.slept != 10*time.Minute { + t.Fatalf("slept %s", box.slept) + } +} + +func TestNoRebootFlag(t *testing.T) { + box := newBox(t, snapN, snapN1) + if _, err := Run(box.env(filepath.Join(t.TempDir(), "u.json")), Options{AutoUpdate: "on", NoReboot: true}); err != nil { + t.Fatal(err) + } + if box.ran("reboot") { + t.Fatal("rebooted with --no-reboot") + } +} + +func TestFailedUpgradeRecordsAndNeverReboots(t *testing.T) { + box := newBox(t, snapN, snapN) + box.failOn = "upgrade" + path := filepath.Join(t.TempDir(), "u.json") + st, err := Run(box.env(path), Options{AutoUpdate: "on"}) + if err == nil || st.Result != ResultFailed { + t.Fatalf("st %+v err %v", st, err) + } + if !strings.Contains(st.Error, "Unable to fetch") { + t.Fatalf("error should carry apt's reason: %q", st.Error) + } + if box.ran("reboot") { + t.Fatal("rebooted after a failure") + } + if saved, ok := ReadState(path); !ok || saved.Result != ResultFailed { + t.Fatalf("saved %+v", saved) + } +} + +func TestFailedAptUpdate(t *testing.T) { + box := newBox(t, snapN) + box.failOn = " update" + st, err := Run(box.env(filepath.Join(t.TempDir(), "u.json")), Options{AutoUpdate: "on"}) + if err == nil || st.Result != ResultFailed || !strings.HasPrefix(st.Error, "apt-get update") { + t.Fatalf("st %+v err %v", st, err) + } + if box.ran("--with-new-pkgs") { + t.Fatal("upgraded after apt-get update failed") + } +} + +func TestLowDiskFailsBeforeTouchingAnything(t *testing.T) { + box := newBox(t) + box.free = 20 << 20 + st, err := Run(box.env(filepath.Join(t.TempDir(), "u.json")), Options{AutoUpdate: "on"}) + if err == nil || st.Result != ResultFailed || !strings.Contains(st.Error, "not enough space") { + t.Fatalf("st %+v err %v", st, err) + } + if len(box.calls) != 0 { + t.Fatalf("ran %q", box.calls) + } +} + +func TestWithoutTheOverlayCommandsRunDirectly(t *testing.T) { + box := newBox(t, snapN, snapN) + box.overlay = false + if _, err := Run(box.env(filepath.Join(t.TempDir(), "u.json")), Options{AutoUpdate: "on"}); err != nil { + t.Fatal(err) + } + if box.ran("chroot") { + t.Fatalf("used the chroot with the overlay off: %q", box.calls) + } +} + +func TestReadStateMissingOrGarbage(t *testing.T) { + dir := t.TempDir() + if _, ok := ReadState(filepath.Join(dir, "nope.json")); ok { + t.Fatal("missing file read as a state") + } +} + +func TestOverlayMeansTheBoxOverlay(t *testing.T) { + box := "/dev/mmcblk0p2 /media/root-ro ext4 ro,relatime 0 0\n" + + "tmpfs-root /media/root-rw tmpfs rw 0 0\n" + + "overlayroot / overlay rw,lowerdir=/media/root-ro 0 0\n" + if !overlayFromMounts(box) { + t.Fatal("the box's overlay not recognized") + } + container := "overlay / overlay rw,lowerdir=/var/lib/docker/... 0 0\n" + if overlayFromMounts(container) { + t.Fatal("a container's overlay root taken for the box's") + } + if overlayFromMounts("/dev/mmcblk0p2 / ext4 rw 0 0\n") { + t.Fatal("a plain root taken for an overlay") + } +} + +func TestTailPrefersAptsOwnErrors(t *testing.T) { + out := "Hit:1 http://deb.debian.org/debian trixie InRelease\nReading package lists...\n" + errOut := "W: OpenPGP signature verification failed: http://x/apt stable InRelease\nE: The repository is not signed.\n" + got := tail(errOut, out) + if got != "W: OpenPGP signature verification failed: http://x/apt stable InRelease / E: The repository is not signed." { + t.Fatalf("got %q", got) + } + if got := tail("", "a\nb\nc\nd\n"); got != "b / c / d" { + t.Fatalf("got %q", got) + } +} diff --git a/build.sh b/build.sh index 0bad7ae..95b9c7c 100755 --- a/build.sh +++ b/build.sh @@ -4,6 +4,9 @@ # What this adds on top of calling pi-gen directly: # - resolves the image version from `git describe` and pins the output name # to syncphony-box--arm64.img.xz (+ .sha256) +# - builds boxd and the syncphony-box .deb (scripts/build-deb.sh), which +# the custom stage installs — and which lands in deploy/ too, for the +# apt repo (ADR 0012) # - neutralizes stage2's EXPORT_IMAGE (pi-gen's documented SKIP_IMAGES # mechanism) so only stage-syncphony exports an image # - adds the third (/data) partition after export (scripts/ @@ -25,20 +28,27 @@ if [ ! -f pi-gen/build-docker.sh ]; then exit 1 fi -VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo unversioned)" +VERSION="$(scripts/version.sh)" IMG_BASE="syncphony-box-${VERSION}-arm64" -# Build boxd (static, arm64 — Go cross-compiles) for the custom stage to -# install. Vendored dependencies, so this needs no network. +# Build boxd (static, arm64 — Go cross-compiles) and package it with the +# rest of the box's files. Vendored dependencies, so this needs no network. if ! command -v go >/dev/null 2>&1; then echo "Go is required to build boxd (https://go.dev/dl/)." >&2 exit 1 fi echo "Building boxd ${VERSION} for arm64..." +mkdir -p .build ( cd boxd && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -mod=vendor -trimpath \ -ldflags "-s -w -X main.version=${VERSION}" \ - -o "../stage-syncphony/05-boxd/files/boxd" ./cmd/boxd ) + -o "../.build/boxd-arm64" ./cmd/boxd ) + +rm -rf .build/deb +DEB="$(scripts/build-deb.sh arm64 .build/boxd-arm64 .build/deb)" +mkdir -p stage-syncphony/00-packages/files +cp "${DEB}" stage-syncphony/00-packages/files/syncphony-box.deb +echo "Packaged ${DEB}" # Only stage-syncphony/EXPORT_IMAGE (empty IMG_SUFFIX) exports an image. touch pi-gen/stage2/SKIP_IMAGES @@ -69,6 +79,9 @@ echo "Adding the /data partition..." ./scripts/add-data-partition.sh "${IMAGE}" sha256sum "${IMAGE}" > "${IMAGE}.sha256" +# The package the image carries, for the apt repo (the release job +# publishes it). +cp "${DEB}" deploy/ echo echo "Built ${IMAGE}" diff --git a/docs/adr/0011-releases-and-imager.md b/docs/adr/0011-releases-and-imager.md new file mode 100644 index 0000000..34b3363 --- /dev/null +++ b/docs/adr/0011-releases-and-imager.md @@ -0,0 +1,103 @@ +# ADR 0011: Releases and the Raspberry Pi Imager listing + +- Status: Accepted +- Date: 2026-10-15 +- Deciders: Phase 11 plan (MAD-797), releases issue (MAD-809) + +## Context + +Until now the image only existed as a CI workflow artifact: you needed a +GitHub login to download it, and nothing told Raspberry Pi Imager about +it, so its customization (Wi-Fi, user, SSH, hostname) only worked if you +picked "Use custom" and happened to know which init format the image +expects. The goal: someone with only Imager and the README goes from +nothing to a playing box. + +## Decision + +### A tag is a release + +The `build` workflow also runs for `v*` tags. The image job is the same +one that runs on every PR (pi-gen on a native arm64 runner, then the +smoke test), so what's released is exactly what was tested. A `release` +job then: + +1. Checks its inputs. The repo's apt key (ADR 0012) is committed and the + `APT_SIGNING_KEY` secret is set, because a release image that can't + update itself must not ship. `CHANGELOG.md` has a `## [X.Y.Z]` section, + because a release without notes fails rather than shipping blank. +2. Writes `SHA256SUMS` over the image and the .deb and signs it with + **cosign, keyless**. +3. Generates the Imager listing. +4. Creates the GitHub Release: `syncphony-box-vX.Y.Z-arm64.img.xz` and + its `.sha256`, the .deb, `SHA256SUMS` with `SHA256SUMS.sigstore.json`, + and `os_list.json`, with the changelog section as the notes. +5. Publishes the apt repo and the Imager listing to the `gh-pages` + branch, which GitHub Pages serves. + +The version comes from `git describe` everywhere (`scripts/version.sh`): +the image file name, boxd's `-X main.version` (which `/v1/info` reports) +and, in Debian form, the .deb. + +### Signing: cosign keyless, not minisign + +The issue allowed minisign or cosign. A minisign key is another long-lived +secret to guard and rotate. Cosign's keyless mode signs with a short-lived +certificate that Sigstore issues to *this workflow at this tag* through +GitHub's OIDC token, and the transparency log records it. There is nothing +to leak, and verifying proves the file came from this repo's release +workflow at a `v*` tag: `cosign verify-blob` with +`--certificate-identity-regexp` pinned to `.github/workflows/build.yml@refs/tags/v` +and the GitHub Actions OIDC issuer. Imager itself checks the image against +the listing's `extract_sha256` while writing, so most people never need +cosign. It's for anyone who wants proof. + +The apt repo is the exception: apt only understands OpenPGP, so it has +its own key (ADR 0012). + +### The Imager listing + +`imager/os_list.json` on GitHub Pages +(`https://madeofpendletonwool.github.io/syncphony-box/imager/os_list.json`) +holds one entry, regenerated on every release by +`scripts/imager-os-list.py`. That script streams the `.img.xz` once for the +download size and SHA-256 and for the extracted size and SHA-256 (all four +of which Imager needs). The URL points at the GitHub Release asset, so the +listing is always the latest release. + +- **`init_format: "cloudinit-rpi"`.** The image is Raspberry Pi OS Trixie + with cloud-init's NoCloud seed on the boot partition (pi-gen's + `ENABLE_CLOUD_INIT=1`, ADR 0001). That is the format Raspberry Pi's own + Trixie Lite entry declares in Imager's official list (checked + 2026-10-15). With it, Imager writes `user-data`/`network-config` the way + this image reads them: username, password, Wi-Fi, SSH, hostname and + locale all apply. `systemd` (the old `firstrun.sh`) would be silently + ignored. +- **`devices: ["pi5-64bit", "pi4-64bit"]`**, the boards this image + supports. Imager hides it for a Pi 3. +- A 40×40 icon, the Syncphony waveform. + +People add it under Imager's App Options → Content Repository → custom +URL, or with `rpi-imager --repo `. Getting into Raspberry Pi's own +curated list is a separate, later conversation with them. + +### Size: under about 1 GB compressed + +The custom stage strips documentation, man pages, info pages, lintian +overrides and translations (except English and Chromium's `en-US.pak`), +keeping the copyright files. The `syncphony-box` package ships the same +rules as a dpkg `path-exclude` drop-in, so packages installed later +(including by the nightly update) never bring them back. The smoke test +fails the build if the `.img.xz` passes 1 GiB. + +## Consequences + +- GitHub Pages must be enabled for the repo, serving the `gh-pages` + branch. The first release creates the branch. One-time setup is in + `docs/releasing.md`. +- The listing always points at the newest release. Older images stay on + the Releases page. +- A release depends on Sigstore (for signing) and GitHub Pages. If either + is down, the release job fails before anything public changes, except + that the GitHub Release is created before the Pages push. A failed Pages + push is retried by re-running the job after deleting the release. diff --git a/docs/adr/0012-box-updates.md b/docs/adr/0012-box-updates.md new file mode 100644 index 0000000..57a78c9 --- /dev/null +++ b/docs/adr/0012-box-updates.md @@ -0,0 +1,145 @@ +# ADR 0012: Box updates + +- Status: Accepted (overnight hardware run pending; see docs/testing.md) +- Date: 2026-10-15 +- Deciders: Phase 11 plan (MAD-797), updates issue (MAD-810) + +## Context + +A box is flashed once and then lives behind a TV for years. The web app +updates itself because the Syncphony server serves it. But the OS (Debian +security updates) and the box's own software (boxd, the kiosk wrapper, the +units) were frozen at flash time. Two constraints shape how they can +update: + +- **The root is read-only behind an overlay** (ADR 0007). A plain + `apt upgrade` writes into the tmpfs upper layer: it looks applied, then + vanishes at the next boot. Debian's own apt timers were disabled for + exactly that reason. +- **Until now the image copied files in**, scattered over the pi-gen + stages. There was no unit of "the box's software" that could be newer + than what a box has. + +Done when: a box flashed with release N, left on overnight after N+1 is +published, runs N+1 in the morning and is still paired. + +## Decision + +### The box's software is a package + +Every file of the box's own (boxd, the kiosk wrapper, `syncphony-box`, +audio-setup, data-persist, every unit and drop-in, the initramfs overlay +scripts, the udev rule, the journald and watchdog drop-ins, the captive +portal's dnsmasq drop-in, the `syncphony.txt` template) moved from the +stages' `files/` directories to `packaging/root/`, laid out exactly as it +installs. `scripts/build-deb.sh` builds the `syncphony-box` .deb from that +tree plus a boxd binary. The maintainer script (`packaging/debian/postinst`) +does what the stages used to do with `on_chroot`: it creates the kiosk user +and the cec group and enables the units. It is idempotent, because it runs +on every update too. + +- Units ship in `/usr/lib/systemd/system` (packages don't own + `/etc/systemd/system`). Drop-ins, the udev rule and modules-load go in + `/usr/lib` as well. +- Only the files with no `/usr` home stay in `/etc` (the initramfs + scripts, the dnsmasq drop-in and the dpkg slim rules), as conffiles. + Updates run with `--force-confold`, and nobody edits these by hand, so + an update replaces them cleanly. +- A `triggers` file activates `update-initramfs`, so an update that + changes the overlay script reaches the initramfs. + +The image **installs the package** (`stage-syncphony/00-packages/01-run.sh`) +instead of copying files, so the image and the updates can't drift apart. +The stages keep only what a package can't own: the kernel command line, +`config.txt`, fstab, the initramfs module list, the links onto `/data`, +the kiosk user's PipeWire setup, and turning off Debian's apt timers. +That split is also the shared layer the x86-64 build (MAD-846) needs: the +package is distro-neutral and only the stages are pi-gen-specific. + +Versions: tag `v1.3.0` builds `1.3.0`, and a build four commits past it is +`1.3.0+git4.gabc1234`. This sorts the way the releases do, so a dev box is +never "updated" back to the release it was cut after. + +### The apt repo: GitHub Pages, signed, updater-only + +The release job (ADR 0011) adds the .deb to an apt repo on the `gh-pages` +branch (`apt/`, one suite `stable`, keeping the last five versions per +architecture) and signs `Release` with an OpenPGP key. The private half +is the `APT_SIGNING_KEY` secret. The public half is committed +(`packaging/apt/syncphony-box-archive-keyring.asc`) and shipped by the +package itself in `/usr/share/keyrings`, so a key rotation reaches every +box as an ordinary update. + +The repo's source line (`/usr/share/syncphony-box/apt/syncphony-box.list`) +is **not** in `/etc/apt/sources.list.d`. `boxd update` hands it to apt +with `-o Dir::Etc::SourceList=…`. On Pi OS the stock `sources.list` is +empty; Debian and Raspberry Pi live in `sources.list.d` and still apply. +The reason: pi-gen's export step runs `apt-get update && dist-upgrade` +on the image. If the box repo were configured, that would pull the +newest *published* package over the one the build just made, and it +would fail outright on a build before the repo exists. + +### The nightly run: `boxd update` + +`syncphony-update.timer` fires at 04:00 box-local time (Imager's timezone, +UTC otherwise; ten minutes of random spread; not `Persistent`, so a box +switched on at 09:00 doesn't start updating mid-party). It runs +`boxd update`: + +1. `auto_update=off` in `syncphony.txt` → skip. +2. The room is playing → skip tonight. "Playing" is boxd's view: the + page's last bridge event is `playing` **and** its heartbeat is fresh + (a dead page's last word doesn't count). boxd exposes it on `/status`. +3. Under the overlay, every command runs on the real root through + `syncphony-box chroot` (ADR 0007's maintenance path). The chroot now + also brings `/data` (so the cloud-init and journal links resolve for + package scripts) and a temporarily read-write `/boot/firmware` (a + kernel or firmware update writes there), then puts it back to read-only. +4. Less than 150 MB free on the root → fail before touching anything (the + root never grows; pi-gen leaves about 20% headroom). +5. `apt-get --error-on=any update`. A repo that can't be fetched or whose + signature fails is a failure, not plain apt's warning-and-carry-on. Then + `apt-get --with-new-pkgs upgrade`, so a new kernel or a new dependency of + `syncphony-box` arrives as a new package. Then `apt-get clean`. +6. Compare `dpkg-query` before and after, and record the result in + `/data/syncphony-box/update.json`: `updated`, `current` or `failed`, + when, the installed `syncphony-box` version, what changed, and (for a + failure) apt's own `E:`/`W:` lines. +7. Something changed → reboot as soon as the room is idle (polling every + minute, for up to four hours; after that, the next boot picks it up). + Nothing changed, or a failure → no reboot. + +`sudo syncphony-box update` runs the same thing by hand (`--manual`: it +runs even with `auto_update=off` or music playing, but the reboot still +waits for a pause). `--no-reboot` leaves the reboot to the person. A lock +file keeps the timer and a manual run from overlapping. + +`/v1/info` gains an `update` object (`at`, `result`, `error` if any) next +to the existing `version`. The room's Screens list shows it (MAD-811). + +### Accounts survive updates + +`data-persist restore` used to copy the saved account files over `/etc` +on every boot. An update that adds a system user or group to the real +root would have lost it at the next boot. `restore` now merges: saved +entries win (the Imager user and its password), and accounts that only the +image has are kept. + +### Failure: keep it simple + +A failed update records `failed`, never reboots, and retries the next +night. The running system is the old one: the overlay's lower layer may +have half-changed underneath, but nothing reloads it before a reboot. If +a box doesn't boot after an update, the documented fix is to reflash and +re-pair. A/B partitions are out of scope. + +## Consequences + +- Images built before this change have no repo and no updater; they need + one reflash to join. +- A build without the repo key committed (a fork, a dev checkout) still + builds and updates Debian packages, but never updates its own software. + The release job refuses to publish such an image. +- `sudo syncphony-box chroot apt upgrade` by hand doesn't see the box + repo. Use `sudo syncphony-box update`. +- Every box polls GitHub Pages and the Debian mirrors once a night. diff --git a/docs/persistent-paths.md b/docs/persistent-paths.md index 4aa9eda..7208c82 100644 --- a/docs/persistent-paths.md +++ b/docs/persistent-paths.md @@ -10,6 +10,7 @@ to or moved off `/data`, this list changes in the same commit. |---|---|---|---| | Chromium profile (pairing cookie, speaker flag) | `/data/chromium` | kiosk user; path pinned by boxd's `config.DefaultProfileDir` | the box's whole identity — the room pairing | | applied config | `/data/syncphony-box/config.env` | `boxd config apply` / the setup form | parsed `syncphony.txt`; `/etc/syncphony-box/config.env` is a symlink to it | +| last update result | `/data/syncphony-box/update.json` | `boxd update` (the nightly timer, or `sudo syncphony-box update`) | when the last update ran, `updated`/`current`/`failed`, the installed version; `/v1/info` reports it (ADR 0012) | | boxd's own state | `/data/boxd` | boxd (as needed) | daemon state; today `network-seen`, the marker that this box has been online once (the setup hotspot then waits 5 minutes, not 1 — ADR 0010) | | journald logs | `/data/journal` | journald (`/var/log/journal` → symlink) | persistent logs, capped at 50 MB (`SystemMaxUse=50M`) | | machine identity | `/data/system/machine-id` | `data-persist save` (the bound id) | seeded into `/etc/machine-id` by the initramfs overlay script | diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..2472a22 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,110 @@ +# Releasing + +How a version reaches people: in Raspberry Pi Imager (new boxes) and over +the nightly update (existing ones). The why is in +[ADR 0011](adr/0011-releases-and-imager.md) and +[ADR 0012](adr/0012-box-updates.md). + +## One-time setup + +### 1. The apt signing key + +Boxes trust the apt repo through an OpenPGP key: the public half is +committed and shipped inside every image, and the private half is a +repository secret. Make one on a trusted machine (no passphrase, because CI +signs unattended; the secret store protects it): + +```sh +export GNUPGHOME="$(mktemp -d)" +gpg --batch --passphrase '' --quick-gen-key \ + 'Syncphony Box apt repository ' \ + ed25519 sign 5y +KEY="$(gpg --list-keys --with-colons | awk -F: '/^fpr/ { print $10; exit }')" + +# The public half: commit it. +gpg --armor --export "${KEY}" > packaging/apt/syncphony-box-archive-keyring.asc + +# The private half: the APT_SIGNING_KEY secret. +gpg --armor --export-secret-keys "${KEY}" | gh secret set APT_SIGNING_KEY + +# Keep an offline backup of the private key, then: +rm -rf "${GNUPGHOME}" +``` + +**Rotating it** (expiry, or a leak) takes two releases, because boxes +only trust the key their installed package shipped: + +1. Make the new key and commit its public half, but leave the + `APT_SIGNING_KEY` secret on the **old** key. Release. Boxes verify + this release with the old key they trust, and it installs the new + public key. +2. Switch the secret to the new key. Every later release is signed with + it. + +After a leak, whoever holds the old key can sign packages that boxes +which haven't taken step 1 will still accept. Release step 1 quickly, +and reflash any box you're unsure of. + +### 2. GitHub Pages + +The first release creates the `gh-pages` branch. Then, under the repo's +Settings → Pages: Source **Deploy from a branch**, branch **gh-pages**, +folder **/ (root)**. From then on: + +- `https://madeofpendletonwool.github.io/syncphony-box/imager/os_list.json` + is the Imager listing +- `https://madeofpendletonwool.github.io/syncphony-box/apt` is the apt + repo + +Nothing to do for signing the checksums: cosign signs keylessly with the +workflow's own GitHub identity. + +## Cutting a release + +1. Make sure `main` is green. +2. In `CHANGELOG.md`, rename `## [Unreleased]` to `## [X.Y.Z]` (add a new + empty `## [Unreleased]` above it), and commit. +3. Tag and push: + + ```sh + git tag -a vX.Y.Z -m "Syncphony Box X.Y.Z" + git push origin vX.Y.Z + ``` + +4. The `build` workflow builds and smoke-tests the image, then the + `release` job publishes: + - the GitHub Release: `syncphony-box-vX.Y.Z-arm64.img.xz` (+ `.sha256`), + `syncphony-box_X.Y.Z_arm64.deb`, `SHA256SUMS`, + `SHA256SUMS.sigstore.json`, `os_list.json`, and the changelog + section as the notes; + - on `gh-pages`: the .deb added to the apt repo (re-signed), and + `imager/os_list.json` pointing at the new image. +5. Boxes pick it up at their next 04:00. To see one take it right away: + `sudo syncphony-box update` on the box. + +The release job refuses to run without the changelog section, the +committed public key or the `APT_SIGNING_KEY` secret. + +**If the job fails after creating the GitHub Release** (the Pages push +is the last step), delete the release (`gh release delete vX.Y.Z`, keep +the tag) and re-run the failed job. + +## Version numbers + +`scripts/version.sh` is the one definition: `git describe` for the image +and boxd (`v1.3.0`, `v1.3.0-4-gabc1234`), and its Debian form for the +.deb (`1.3.0`, `1.3.0+git4.gabc1234`). A dev build sorts after the +release it was cut from and before the next one, so a dev box is never +"updated" back to an older release. + +## When an update goes wrong on a box + +A failed update changes nothing visible: the box keeps running what it +had, the room's Screens list shows "update failed" (from `/v1/info`), and +the next night retries. Details: `journalctl -u syncphony-update` and +`/data/syncphony-box/update.json`. + +If a box doesn't come back after an update: reflash the card with the +current release in Imager and pair it again from the room. There are no +A/B partitions to fall back to (ADR 0012). Please open an issue with +what the box was running (the Screens list shows the version). diff --git a/docs/testing.md b/docs/testing.md index a53194c..ba52fc9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -253,3 +253,45 @@ Ethernet cable. A serial console or a keyboard helps you watch 8. **Ethernet wins.** With the hotspot up, plug in a cable. The hotspot goes down within seconds and the TV goes back to the room (or the setup screen, if no server is set). + +## The overnight update (MAD-810) + +The done-when for updates: a box flashed with release N, left on +overnight after N+1 is published, runs N+1 in the morning and is still +paired. Takes two consecutive releases (cut a throwaway `vX.Y.Z-test` +pair if needed) and one night, or a shortcut with the timer. + +1. Flash release N with Imager (from the listing), set Wi-Fi and the + timezone, and pair the box with a room. Play something, then stop. +2. Publish release N+1 (docs/releasing.md). +3. Leave the box on, idle, past 04:00 box-local time. Shortcut: + `sudo systemctl start syncphony-update.service` (the nightly path, + playback check included) instead of waiting. +4. In the morning: + - `syncphony-box version` prints N+1; + - `journalctl -u syncphony-update -b -1` shows the upgrade and + "rebooting into the update"; `last reboot` shows one reboot near 04:00; + - the TV is in the room, paired, and plays when you queue a song; + - `findmnt /` is still the overlay, `/boot/firmware` is `ro` again; + - `curl -s 127.0.0.1:8099/v1/info` has `"update": {"result": "updated", ...}` + and `"version"` N+1; + - `getent passwd` still has the Imager user (and the password works). + +Then the negative cases: + +- **Playing at 04:00.** Queue a long playlist before 04:00: the journal + says "the room is playing; skipping tonight's update", nothing + installs, the music never stops. +- **`auto_update=off`.** Set it on the card, boot, run + `sudo systemctl start syncphony-update.service`: skipped. + `sudo syncphony-box update` still updates. +- **Nothing new.** A second `sudo syncphony-box update`: `current`, no + reboot. +- **No network.** Unplug the network and run + `sudo systemctl start syncphony-update.service`: `result: failed` with + apt's reason in `/v1/info`, no reboot, the box keeps playing its room + once the network is back. +- **A kernel update.** When a release window includes a Raspberry Pi + kernel update, check that it lands: `uname -r` after the reboot + matches the newest `/lib/modules`, and the box still boots with the + overlay (the initramfs was regenerated with the overlay script). diff --git a/packaging/apt/syncphony-box.list b/packaging/apt/syncphony-box.list new file mode 100644 index 0000000..fe2c611 --- /dev/null +++ b/packaging/apt/syncphony-box.list @@ -0,0 +1,6 @@ +# The syncphony-box apt repository (ADR 0012): the box's own software. +# Not in /etc/apt/sources.list.d on purpose: only the box's updater uses +# it (boxd update passes -o Dir::Etc::SourceList=), so the +# image build's apt runs never pull a published package over the one the +# build just made, and a build before the repo exists still works. +deb [signed-by=/usr/share/keyrings/syncphony-box-archive-keyring.asc] @APT_URL@ stable main diff --git a/packaging/debian/control.in b/packaging/debian/control.in new file mode 100644 index 0000000..183c359 --- /dev/null +++ b/packaging/debian/control.in @@ -0,0 +1,18 @@ +Package: syncphony-box +Version: @VERSION@ +Architecture: @ARCH@ +Maintainer: Syncphony Box +Section: misc +Priority: optional +Homepage: https://github.com/madeofpendletonwool/syncphony-box +Installed-Size: @INSTALLED_SIZE@ +Depends: adduser, systemd, initramfs-tools, cage, chromium, curl, ca-certificates, avahi-daemon, pipewire, pipewire-pulse, wireplumber, pulseaudio-utils, network-manager, dnsmasq-base, v4l-utils, systemd-timesyncd +Description: Syncphony Box: a TV's big screen and speaker for a Syncphony room + The software that makes a computer plugged into a TV a Syncphony box: + boxd (the box bridge, setup and offline screens, watchdogs, HDMI-CEC and + the Wi-Fi setup hotspot), the cage + Chromium kiosk session, the audio + setup, the read-only root and /data machinery, and the nightly updater. + . + It is built for the syncphony-box image and expects that image's layout + (the read-only root behind an overlay, /data, the boot partition with + syncphony.txt). Installing it on an ordinary desktop is not supported. diff --git a/packaging/debian/postinst b/packaging/debian/postinst new file mode 100755 index 0000000..a45b585 --- /dev/null +++ b/packaging/debian/postinst @@ -0,0 +1,40 @@ +#!/bin/sh +# syncphony-box postinst. Runs at image build (inside pi-gen's chroot) and +# on every update (inside `syncphony-box chroot`, on the real root of a +# running box — no live systemd to talk to, the box reboots afterwards). +# Everything here must be idempotent. +set -e + +UNITS="boxd.service syncphony-box-config.service syncphony-kiosk.service \ + syncphony-audio.service syncphony-audio-monitor.service \ + syncphony-data-grow.service syncphony-data-prepare.service \ + syncphony-data-save.service syncphony-data-save.timer \ + syncphony-update.timer" + +case "$1" in +configure) + # The kiosk user: locked password, no shell, no sudo. Seat-device + # access comes from the logind session plus these groups (ADR 0002). + if ! getent passwd kiosk >/dev/null; then + adduser --system --group --home /var/lib/syncphony-box \ + --shell /usr/sbin/nologin kiosk + fi + for grp in video render audio input; do + if getent group "${grp}" >/dev/null; then + adduser --quiet kiosk "${grp}" + fi + done + install -d -o kiosk -g kiosk -m 0755 /var/lib/syncphony-box + + # /dev/cec* and /dev/uinput go to this group (the udev rule); boxd + # holds it through its drop-in (ADR 0009). + getent group cec >/dev/null || addgroup --system cec + + # Enabled on every configure so a unit added by an update is on after + # the reboot. systemctl works offline in a chroot. + # shellcheck disable=SC2086 + systemctl enable ${UNITS} + ;; +esac + +exit 0 diff --git a/packaging/debian/triggers b/packaging/debian/triggers new file mode 100644 index 0000000..407c7e3 --- /dev/null +++ b/packaging/debian/triggers @@ -0,0 +1 @@ +activate-noawait update-initramfs diff --git a/packaging/imager/icon.png b/packaging/imager/icon.png new file mode 100644 index 0000000..f6cf9ee Binary files /dev/null and b/packaging/imager/icon.png differ diff --git a/stage-syncphony/09-wifi-setup/files/syncphony-captive.conf b/packaging/root/etc/NetworkManager/dnsmasq-shared.d/syncphony-captive.conf similarity index 100% rename from stage-syncphony/09-wifi-setup/files/syncphony-captive.conf rename to packaging/root/etc/NetworkManager/dnsmasq-shared.d/syncphony-captive.conf diff --git a/packaging/root/etc/dpkg/dpkg.cfg.d/50-syncphony-slim b/packaging/root/etc/dpkg/dpkg.cfg.d/50-syncphony-slim new file mode 100644 index 0000000..497e8c5 --- /dev/null +++ b/packaging/root/etc/dpkg/dpkg.cfg.d/50-syncphony-slim @@ -0,0 +1,15 @@ +# Keep the box image small (MAD-809): no documentation, man pages or +# translations. The copyright files stay. Applies to updates too; the +# image build strips what stage0-2 already installed (stage-syncphony/ +# 10-slim). +path-exclude /usr/share/doc/* +path-include /usr/share/doc/*/copyright +path-exclude /usr/share/man/* +path-exclude /usr/share/info/* +path-exclude /usr/share/lintian/* +path-exclude /usr/share/locale/* +path-include /usr/share/locale/locale.alias +path-include /usr/share/locale/en/* +path-include /usr/share/locale/en_US/* +path-exclude /usr/lib/chromium/locales/* +path-include /usr/lib/chromium/locales/en-US.pak diff --git a/stage-syncphony/06-readonly-root/files/grow-data b/packaging/root/etc/initramfs-tools/scripts/local-premount/grow-data similarity index 100% rename from stage-syncphony/06-readonly-root/files/grow-data rename to packaging/root/etc/initramfs-tools/scripts/local-premount/grow-data diff --git a/stage-syncphony/06-readonly-root/files/overlay b/packaging/root/etc/initramfs-tools/scripts/overlay similarity index 100% rename from stage-syncphony/06-readonly-root/files/overlay rename to packaging/root/etc/initramfs-tools/scripts/overlay diff --git a/stage-syncphony/01-kiosk/files/syncphony-box b/packaging/root/usr/bin/syncphony-box old mode 100644 new mode 100755 similarity index 65% rename from stage-syncphony/01-kiosk/files/syncphony-box rename to packaging/root/usr/bin/syncphony-box index 3918654..a35bc8b --- a/stage-syncphony/01-kiosk/files/syncphony-box +++ b/packaging/root/usr/bin/syncphony-box @@ -13,6 +13,12 @@ # into it (a shell with no CMD), so package # updates and config edits stick. Reboot # afterwards. See docs/adr/0007. +# sudo syncphony-box update [--no-reboot] +# install Debian security updates and the +# newest box software now (what the nightly +# timer does), and reboot into them once the +# room is idle. See docs/adr/0012. +# syncphony-box version the installed box software version. set -eu @@ -39,6 +45,9 @@ usage() { usage: sudo syncphony-box reset wipe the pairing (new pairing code) sudo syncphony-box chroot [CMD...] shell/command on the real root fs + sudo syncphony-box update [--no-reboot] + install updates now; reboot when idle + syncphony-box version the installed box software version EOF } @@ -72,18 +81,39 @@ chroot) RW=/media/root-rw mkdir -p "${RW}" mount -o rw "${SRC}" "${RW}" + boot_rw="" cleanup() { cd / - umount -l "${RW}/dev" "${RW}/proc" "${RW}/sys" 2>/dev/null || true + umount -l "${RW}/boot/firmware" "${RW}/data" "${RW}/dev" "${RW}/proc" "${RW}/sys" 2>/dev/null || true + if [ -n "${boot_rw}" ]; then + mount -o remount,ro /boot/firmware 2>/dev/null || true + fi umount "${RW}" } trap cleanup EXIT mount --rbind /dev "${RW}/dev" mount --rbind /proc "${RW}/proc" mount --rbind /sys "${RW}/sys" + # /data: the paths symlinked onto it (cloud-init's state, the journal) + # must resolve for package scripts too. + if mountpoint -q /data; then + mount --bind /data "${RW}/data" + fi + # /boot/firmware, writable for the duration: a kernel or firmware + # update writes the kernel, the DTBs and the initramfs there. It goes + # back to read-only on the way out (boxd does the same around its + # syncphony.txt writes). + if mountpoint -q /boot/firmware; then + if findmnt -n -o OPTIONS /boot/firmware | grep -qE '(^|,)ro(,|$)'; then + mount -o remount,rw /boot/firmware + boot_rw=1 + fi + mount --bind /boot/firmware "${RW}/boot/firmware" + fi cp /etc/resolv.conf "${RW}/etc/resolv.conf" 2>/dev/null || true - echo "Real root mounted read-write at ${RW} — changes here survive reboots;" - echo "anything you used to change under / was going to tmpfs. Reboot when done." + # On stderr: `boxd update` parses the chrooted command's stdout. + echo "Real root mounted read-write at ${RW} — changes here survive reboots;" >&2 + echo "anything you used to change under / was going to tmpfs. Reboot when done." >&2 if [ "$#" -gt 1 ]; then shift chroot "${RW}" "$@" @@ -91,6 +121,14 @@ chroot) chroot "${RW}" /bin/bash fi ;; +update) + need_root update + shift + exec "${BOXD_BIN}" update --manual "$@" + ;; +version) + dpkg-query -W -f='${Version}\n' syncphony-box 2>/dev/null || "${BOXD_BIN}" version + ;; help|-h|--help) usage ;; diff --git a/stage-syncphony/08-cec/files/uinput.conf b/packaging/root/usr/lib/modules-load.d/syncphony-cec.conf similarity index 100% rename from stage-syncphony/08-cec/files/uinput.conf rename to packaging/root/usr/lib/modules-load.d/syncphony-cec.conf diff --git a/stage-syncphony/04-audio/files/audio-setup b/packaging/root/usr/lib/syncphony-box/audio-setup similarity index 100% rename from stage-syncphony/04-audio/files/audio-setup rename to packaging/root/usr/lib/syncphony-box/audio-setup diff --git a/stage-syncphony/06-readonly-root/files/data-persist b/packaging/root/usr/lib/syncphony-box/data-persist similarity index 80% rename from stage-syncphony/06-readonly-root/files/data-persist rename to packaging/root/usr/lib/syncphony-box/data-persist index 5fd8885..6bea28b 100755 --- a/stage-syncphony/06-readonly-root/files/data-persist +++ b/packaging/root/usr/lib/syncphony-box/data-persist @@ -61,15 +61,40 @@ ensure_layout() { chown kiosk:kiosk "${DATA}/chromium" 2>/dev/null || true } +# restore_identity merges one saved account file over the image's: every +# saved entry wins (the Imager user, its password, its groups), and an +# account the image has but the save doesn't is kept. That second half is +# what the nightly update needs (ADR 0012): a package that adds a system +# user or group writes it into the real root, and a plain copy of the old +# save would drop it again at every boot. +restore_identity() { + f="$1" + target="${ETC}/${f}" + if [ ! -f "${target}" ]; then + cp -p "${IDENT}/${f}" "${target}" + return + fi + tmp="${target}.data-persist" + awk -F: ' + NR == FNR { if ($1 != "") { saved[$1] = 1; print } ; next } + $1 != "" && !($1 in saved) + ' "${IDENT}/${f}" "${target}" > "${tmp}" + # Keep the image file's owner and mode (shadow is root:shadow 0640). + chmod --reference="${target}" "${tmp}" 2>/dev/null || true + chown --reference="${target}" "${tmp}" 2>/dev/null || true + mv -f "${tmp}" "${target}" +} + restore() { data_usable || exit 0 ensure_layout - # The account files: whatever the last save had. On a fresh card this - # is a no-op and cloud-init creates the user from the Imager settings. + # The account files: whatever the last save had, merged over the + # image's. On a fresh card this is a no-op and cloud-init creates the + # user from the Imager settings. for f in ${IDENTITY_FILES}; do if [ -f "${IDENT}/${f}" ]; then - cp -p "${IDENT}/${f}" "${ETC}/${f}" + restore_identity "${f}" fi done diff --git a/stage-syncphony/01-kiosk/files/kiosk b/packaging/root/usr/lib/syncphony-box/kiosk old mode 100644 new mode 100755 similarity index 100% rename from stage-syncphony/01-kiosk/files/kiosk rename to packaging/root/usr/lib/syncphony-box/kiosk diff --git a/stage-syncphony/03-config/files/syncphony.txt b/packaging/root/usr/lib/syncphony-box/syncphony.txt similarity index 84% rename from stage-syncphony/03-config/files/syncphony.txt rename to packaging/root/usr/lib/syncphony-box/syncphony.txt index c3b717f..49647d5 100644 --- a/stage-syncphony/03-config/files/syncphony.txt +++ b/packaging/root/usr/lib/syncphony-box/syncphony.txt @@ -22,3 +22,7 @@ #cec_standby=off # How many idle minutes before that standby: 1 to 240. #cec_standby_minutes=30 +# Install Debian security updates and new box software every night around +# 04:00 (never while music plays; the box reboots afterwards only if +# something changed): on/off. +#auto_update=on diff --git a/stage-syncphony/01-kiosk/files/unconfigured.html b/packaging/root/usr/lib/syncphony-box/unconfigured.html similarity index 100% rename from stage-syncphony/01-kiosk/files/unconfigured.html rename to packaging/root/usr/lib/syncphony-box/unconfigured.html diff --git a/stage-syncphony/06-readonly-root/files/50-syncphony-journald.conf b/packaging/root/usr/lib/systemd/journald.conf.d/50-syncphony-journald.conf similarity index 100% rename from stage-syncphony/06-readonly-root/files/50-syncphony-journald.conf rename to packaging/root/usr/lib/systemd/journald.conf.d/50-syncphony-journald.conf diff --git a/stage-syncphony/07-watchdog/files/50-syncphony-watchdog.conf b/packaging/root/usr/lib/systemd/system.conf.d/50-syncphony-watchdog.conf similarity index 100% rename from stage-syncphony/07-watchdog/files/50-syncphony-watchdog.conf rename to packaging/root/usr/lib/systemd/system.conf.d/50-syncphony-watchdog.conf diff --git a/stage-syncphony/05-boxd/files/boxd.service b/packaging/root/usr/lib/systemd/system/boxd.service similarity index 100% rename from stage-syncphony/05-boxd/files/boxd.service rename to packaging/root/usr/lib/systemd/system/boxd.service diff --git a/stage-syncphony/08-cec/files/50-cec.conf b/packaging/root/usr/lib/systemd/system/boxd.service.d/50-cec.conf similarity index 100% rename from stage-syncphony/08-cec/files/50-cec.conf rename to packaging/root/usr/lib/systemd/system/boxd.service.d/50-cec.conf diff --git a/stage-syncphony/09-wifi-setup/files/60-network.conf b/packaging/root/usr/lib/systemd/system/boxd.service.d/60-network.conf similarity index 100% rename from stage-syncphony/09-wifi-setup/files/60-network.conf rename to packaging/root/usr/lib/systemd/system/boxd.service.d/60-network.conf diff --git a/stage-syncphony/04-audio/files/syncphony-audio-monitor.service b/packaging/root/usr/lib/systemd/system/syncphony-audio-monitor.service similarity index 100% rename from stage-syncphony/04-audio/files/syncphony-audio-monitor.service rename to packaging/root/usr/lib/systemd/system/syncphony-audio-monitor.service diff --git a/stage-syncphony/04-audio/files/syncphony-audio.service b/packaging/root/usr/lib/systemd/system/syncphony-audio.service similarity index 100% rename from stage-syncphony/04-audio/files/syncphony-audio.service rename to packaging/root/usr/lib/systemd/system/syncphony-audio.service diff --git a/stage-syncphony/03-config/files/syncphony-box-config.service b/packaging/root/usr/lib/systemd/system/syncphony-box-config.service similarity index 100% rename from stage-syncphony/03-config/files/syncphony-box-config.service rename to packaging/root/usr/lib/systemd/system/syncphony-box-config.service diff --git a/stage-syncphony/06-readonly-root/files/syncphony-data-grow.service b/packaging/root/usr/lib/systemd/system/syncphony-data-grow.service similarity index 100% rename from stage-syncphony/06-readonly-root/files/syncphony-data-grow.service rename to packaging/root/usr/lib/systemd/system/syncphony-data-grow.service diff --git a/stage-syncphony/06-readonly-root/files/syncphony-data-prepare.service b/packaging/root/usr/lib/systemd/system/syncphony-data-prepare.service similarity index 100% rename from stage-syncphony/06-readonly-root/files/syncphony-data-prepare.service rename to packaging/root/usr/lib/systemd/system/syncphony-data-prepare.service diff --git a/stage-syncphony/06-readonly-root/files/syncphony-data-save.service b/packaging/root/usr/lib/systemd/system/syncphony-data-save.service similarity index 100% rename from stage-syncphony/06-readonly-root/files/syncphony-data-save.service rename to packaging/root/usr/lib/systemd/system/syncphony-data-save.service diff --git a/stage-syncphony/06-readonly-root/files/syncphony-data-save.timer b/packaging/root/usr/lib/systemd/system/syncphony-data-save.timer similarity index 100% rename from stage-syncphony/06-readonly-root/files/syncphony-data-save.timer rename to packaging/root/usr/lib/systemd/system/syncphony-data-save.timer diff --git a/stage-syncphony/01-kiosk/files/syncphony-kiosk.service b/packaging/root/usr/lib/systemd/system/syncphony-kiosk.service similarity index 100% rename from stage-syncphony/01-kiosk/files/syncphony-kiosk.service rename to packaging/root/usr/lib/systemd/system/syncphony-kiosk.service diff --git a/packaging/root/usr/lib/systemd/system/syncphony-update.service b/packaging/root/usr/lib/systemd/system/syncphony-update.service new file mode 100644 index 0000000..70e25d0 --- /dev/null +++ b/packaging/root/usr/lib/systemd/system/syncphony-update.service @@ -0,0 +1,18 @@ +[Unit] +Description=Syncphony box update (Debian security updates and the syncphony-box package) +Documentation=https://github.com/madeofpendletonwool/syncphony-box/blob/main/docs/adr/0012-box-updates.md +# boxd answers "is music playing?" and owns the auto_update= setting. +After=network-online.target boxd.service +Wants=network-online.target + +[Service] +Type=oneshot +# boxd update: skips while the room is playing, upgrades the REAL root +# through `syncphony-box chroot`, records the result on /data for +# /v1/info, and reboots only if something changed and only while idle. +ExecStart=/usr/lib/syncphony-box/boxd update +# The idle wait before the reboot can take a while (a party that runs +# late); the run gives up waiting and leaves the reboot to the next boot. +TimeoutStartSec=5h +Nice=10 +IOSchedulingClass=idle diff --git a/packaging/root/usr/lib/systemd/system/syncphony-update.timer b/packaging/root/usr/lib/systemd/system/syncphony-update.timer new file mode 100644 index 0000000..4be2874 --- /dev/null +++ b/packaging/root/usr/lib/systemd/system/syncphony-update.timer @@ -0,0 +1,15 @@ +[Unit] +Description=Nightly Syncphony box update + +[Timer] +# 04:00 box-local time (Imager's timezone setting; UTC otherwise). A few +# minutes of spread so a fleet doesn't hit the repos in the same second. +# Not Persistent: a box that was off at 04:00 must not update at its next +# boot, in the middle of whatever it was switched on for. +OnCalendar=*-*-* 04:00:00 +RandomizedDelaySec=10min +AccuracySec=1min +Persistent=false + +[Install] +WantedBy=timers.target diff --git a/stage-syncphony/08-cec/files/60-syncphony-cec.rules b/packaging/root/usr/lib/udev/rules.d/60-syncphony-cec.rules similarity index 100% rename from stage-syncphony/08-cec/files/60-syncphony-cec.rules rename to packaging/root/usr/lib/udev/rules.d/60-syncphony-cec.rules diff --git a/stage-syncphony/04-audio/files/50-syncphony-box.conf b/packaging/root/usr/share/syncphony-box/wireplumber/50-syncphony-box.conf similarity index 100% rename from stage-syncphony/04-audio/files/50-syncphony-box.conf rename to packaging/root/usr/share/syncphony-box/wireplumber/50-syncphony-box.conf diff --git a/scripts/apt-repo.sh b/scripts/apt-repo.sh new file mode 100755 index 0000000..dd716a9 --- /dev/null +++ b/scripts/apt-repo.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Add packages to the syncphony-box apt repository and re-sign it +# (MAD-810, ADR 0012). The release job runs this on a checkout of the +# gh-pages branch; GitHub Pages serves the result at +# https://madeofpendletonwool.github.io/syncphony-box/apt. +# +# Usage: scripts/apt-repo.sh REPO_DIR DEB... +# +# Environment: +# APT_SIGNING_KEY_ID the gpg key (already imported) that signs Release +# KEEP versions kept per architecture (default 5; older +# ones are pruned — Pages is no archive, the +# GitHub Releases are) +# +# Layout (one suite, "stable", one component, "main"): +# pool/main/s/syncphony-box/syncphony-box__.deb +# dists/stable/main/binary-/Packages{,.gz} +# dists/stable/{Release,Release.gpg,InRelease} +# +# Needs apt-ftparchive (apt-utils), dpkg-deb and gpg. +set -euo pipefail + +if [ "$#" -lt 2 ]; then + echo "usage: $0 REPO_DIR DEB..." >&2 + exit 2 +fi +: "${APT_SIGNING_KEY_ID:?set APT_SIGNING_KEY_ID to the signing key}" +KEEP="${KEEP:-5}" +REPO="$1" +shift + +POOL="pool/main/s/syncphony-box" +mkdir -p "${REPO}/${POOL}" +for deb in "$@"; do + pkg="$(dpkg-deb -f "${deb}" Package)" + if [ "${pkg}" != "syncphony-box" ]; then + echo "refusing ${deb}: package ${pkg}, not syncphony-box" >&2 + exit 1 + fi + cp -v "${deb}" "${REPO}/${POOL}/" +done + +cd "${REPO}" + +# Prune to the newest KEEP versions per architecture. +archs="$(for f in "${POOL}"/*.deb; do dpkg-deb -f "${f}" Architecture; done | sort -u)" +for arch in ${archs}; do + mapfile -t old < <( + for f in "${POOL}"/*_"${arch}".deb; do + printf '%s %s\n' "$(dpkg-deb -f "${f}" Version)" "${f}" + done | sort -V | head -n "-${KEEP}" | cut -d' ' -f2- + ) + for f in "${old[@]}"; do + echo "pruning ${f}" + rm -f "${f}" + done +done + +rm -rf dists/stable +for arch in ${archs}; do + dir="dists/stable/main/binary-${arch}" + mkdir -p "${dir}" + apt-ftparchive --arch "${arch}" packages "${POOL}" > "${dir}/Packages" + gzip -9kn "${dir}/Packages" +done + +apt-ftparchive \ + -o APT::FTPArchive::Release::Origin="Syncphony Box" \ + -o APT::FTPArchive::Release::Label="Syncphony Box" \ + -o APT::FTPArchive::Release::Suite=stable \ + -o APT::FTPArchive::Release::Codename=stable \ + -o APT::FTPArchive::Release::Components=main \ + -o APT::FTPArchive::Release::Architectures="$(echo ${archs})" \ + -o APT::FTPArchive::Release::Description="The Syncphony box's own software" \ + release dists/stable > dists/stable/Release.tmp +mv dists/stable/Release.tmp dists/stable/Release + +gpg --batch --yes --local-user "${APT_SIGNING_KEY_ID}" --clearsign \ + -o dists/stable/InRelease dists/stable/Release +gpg --batch --yes --local-user "${APT_SIGNING_KEY_ID}" --armor --detach-sign \ + -o dists/stable/Release.gpg dists/stable/Release + +echo "apt repo updated: ${archs} — $(find "${POOL}" -name '*.deb' | wc -l) package(s)" diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh new file mode 100755 index 0000000..cde18bc --- /dev/null +++ b/scripts/build-deb.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Build the syncphony-box .deb (MAD-810, ADR 0012): boxd plus every file +# the box's own software is made of — the tree under packaging/root — +# with the maintainer scripts in packaging/debian. The image installs this +# package instead of copying files, and boxes update to newer ones from +# the apt repo, so the image and the updates can't drift apart. +# +# Usage: scripts/build-deb.sh ARCH BOXD_BINARY OUT_DIR +# ARCH Debian architecture: arm64 (Pi) or amd64 (x86, MAD-846) +# BOXD_BINARY a static boxd for that architecture +# OUT_DIR where syncphony-box__.deb lands +# +# Environment: +# DEB_VERSION override the version (default: scripts/version.sh --deb) +# APT_URL the apt repository the package points boxes at +# (default: this repo's GitHub Pages) +# +# The apt source and its keyring are only packaged when the public key +# (packaging/apt/syncphony-box-archive-keyring.asc) is in the checkout. A +# box built without it never updates itself; the release job refuses to +# publish one. +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "usage: $0 ARCH BOXD_BINARY OUT_DIR" >&2 + exit 2 +fi +ARCH="$1" BOXD="$2" OUT="$3" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="${DEB_VERSION:-$("${REPO_ROOT}/scripts/version.sh" --deb)}" +APT_URL="${APT_URL:-https://madeofpendletonwool.github.io/syncphony-box/apt}" +KEYRING="${REPO_ROOT}/packaging/apt/syncphony-box-archive-keyring.asc" + +if ! command -v dpkg-deb >/dev/null 2>&1; then + echo "dpkg-deb is required to build the package (apt install dpkg)." >&2 + exit 1 +fi +if [ ! -f "${BOXD}" ]; then + echo "boxd binary not found: ${BOXD}" >&2 + exit 1 +fi + +STAGING="$(mktemp -d)" +trap 'rm -rf "${STAGING}"' EXIT +PKG="${STAGING}/pkg" + +cp -a "${REPO_ROOT}/packaging/root" "${PKG}" +install -m 0755 "${BOXD}" "${PKG}/usr/lib/syncphony-box/boxd" + +if [ -f "${KEYRING}" ]; then + install -d "${PKG}/usr/share/keyrings" "${PKG}/usr/share/syncphony-box/apt" + install -m 0644 "${KEYRING}" "${PKG}/usr/share/keyrings/syncphony-box-archive-keyring.asc" + sed "s|@APT_URL@|${APT_URL}|" "${REPO_ROOT}/packaging/apt/syncphony-box.list" \ + > "${PKG}/usr/share/syncphony-box/apt/syncphony-box.list" +else + echo "WARNING: ${KEYRING#"${REPO_ROOT}"/} is missing: this package has no apt source," >&2 + echo "WARNING: so a box built from it never updates itself (see docs/releasing.md)." >&2 +fi + +# Git doesn't keep modes beyond the executable bit, and the checkout's +# umask decides the rest: normalize, then mark the scripts executable. +find "${PKG}" -type d -exec chmod 0755 {} + +find "${PKG}" -type f -exec chmod 0644 {} + +chmod 0755 "${PKG}/usr/bin/syncphony-box" \ + "${PKG}/usr/lib/syncphony-box/boxd" \ + "${PKG}/usr/lib/syncphony-box/kiosk" \ + "${PKG}/usr/lib/syncphony-box/audio-setup" \ + "${PKG}/usr/lib/syncphony-box/data-persist" \ + "${PKG}/etc/initramfs-tools/scripts/local-premount/grow-data" + +install -d -m 0755 "${PKG}/DEBIAN" +installed_kb="$(du -sk --apparent-size "${PKG}" | cut -f1)" +sed -e "s|@VERSION@|${VERSION}|" -e "s|@ARCH@|${ARCH}|" -e "s|@INSTALLED_SIZE@|${installed_kb}|" \ + "${REPO_ROOT}/packaging/debian/control.in" > "${PKG}/DEBIAN/control" +install -m 0755 "${REPO_ROOT}/packaging/debian/postinst" "${PKG}/DEBIAN/postinst" +install -m 0644 "${REPO_ROOT}/packaging/debian/triggers" "${PKG}/DEBIAN/triggers" +# Every file under /etc is a conffile (dpkg-deb doesn't mark them itself). +(cd "${PKG}" && find etc -type f | sort | sed 's|^|/|') > "${PKG}/DEBIAN/conffiles" +chmod 0644 "${PKG}/DEBIAN/conffiles" "${PKG}/DEBIAN/control" + +mkdir -p "${OUT}" +DEB="${OUT}/syncphony-box_${VERSION}_${ARCH}.deb" +dpkg-deb --root-owner-group -Zxz --build "${PKG}" "${DEB}" >/dev/null +echo "${DEB}" diff --git a/scripts/changelog-section.sh b/scripts/changelog-section.sh new file mode 100755 index 0000000..48a1880 --- /dev/null +++ b/scripts/changelog-section.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Print one version's section of CHANGELOG.md — the release notes for +# tag v (ADR 0011). Exits 1 (printing nothing) when the section +# is missing, so a release without notes fails instead of shipping blank. +# +# Usage: scripts/changelog-section.sh 1.3.0 (a leading v is accepted) +set -eu + +version="${1#v}" +changelog="$(dirname "$0")/../CHANGELOG.md" + +section="$(awk -v want="${version}" ' + /^## \[/ { + if (found) exit + v = $0 + sub(/^## \[v?/, "", v) + sub(/\].*$/, "", v) + if (v == want) { found = 1; next } + } + found { print } + END { if (!found) exit 1 } +' "${changelog}")" || { + echo "CHANGELOG.md has no '## [${version}]' section" >&2 + exit 1 +} +# $(...) already dropped the trailing blank lines; drop the leading ones. +printf '%s\n' "${section}" | sed -e '/./,$!d' diff --git a/scripts/imager-os-list.py b/scripts/imager-os-list.py new file mode 100755 index 0000000..b3d4dec --- /dev/null +++ b/scripts/imager-os-list.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Write the Raspberry Pi Imager listing for a release (MAD-809, ADR 0011). + +Usage: + scripts/imager-os-list.py IMAGE.img.xz --url URL --icon URL \ + --version VERSION [--release-date YYYY-MM-DD] > os_list.json + +Imager reads this file as a custom "Content Repository" (App Options -> +Content Repository, or `rpi-imager --repo `). It needs the sizes and +the SHA-256 of both the download and the extracted image, so this +streams the .img.xz once and computes them all. + +init_format is "cloudinit-rpi": the image is Raspberry Pi OS Trixie with +cloud-init's NoCloud seed on the boot partition (pi-gen's +ENABLE_CLOUD_INIT=1), the same as Raspberry Pi's own Trixie entries — so +Imager's username, Wi-Fi, SSH, hostname and locale settings apply. +""" + +import argparse +import datetime +import hashlib +import json +import lzma +import os +import sys + +CHUNK = 4 << 20 + + +def measure(path): + """(download size, download sha256, extracted size, extracted sha256).""" + raw = hashlib.sha256() + extracted = hashlib.sha256() + extract_size = 0 + decomp = lzma.LZMADecompressor() + with open(path, "rb") as f: + while True: + chunk = f.read(CHUNK) + if not chunk: + break + raw.update(chunk) + # xz files can hold several streams (pixz, xz -T); start a new + # decompressor at each stream boundary. + while chunk: + out = decomp.decompress(chunk) + extracted.update(out) + extract_size += len(out) + if decomp.eof: + chunk = decomp.unused_data + decomp = lzma.LZMADecompressor() + else: + chunk = b"" + return os.path.getsize(path), raw.hexdigest(), extract_size, extracted.hexdigest() + + +def main(): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("image") + p.add_argument("--url", required=True, help="where Imager downloads the .img.xz") + p.add_argument("--icon", required=True, help="a 40x40 PNG") + p.add_argument("--version", required=True) + p.add_argument("--release-date", default=datetime.date.today().isoformat()) + args = p.parse_args() + + download_size, download_sha, extract_size, extract_sha = measure(args.image) + entry = { + "name": "Syncphony Box", + "description": ( + f"Syncphony Box {args.version}: turns a Raspberry Pi plugged into a TV " + "into a Syncphony room's big screen and speaker (Pi 4 and Pi 5)" + ), + "icon": args.icon, + "url": args.url, + "extract_size": extract_size, + "extract_sha256": extract_sha, + "image_download_size": download_size, + "image_download_sha256": download_sha, + "release_date": args.release_date, + "init_format": "cloudinit-rpi", + "devices": ["pi5-64bit", "pi4-64bit"], + } + json.dump({"os_list": [entry]}, sys.stdout, indent=2) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 9e7c0c3..6de309e 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -23,7 +23,11 @@ # installed, the cec group with its udev rule for /dev/cec* and # /dev/uinput, boxd granted the group, and uinput loaded at boot. And the # Wi-Fi setup hotspot's: NetworkManager and dnsmasq, the captive-portal -# DNS catch-all, and boxd ordered after NetworkManager. +# DNS catch-all, and boxd ordered after NetworkManager. And the release +# and update pieces (MAD-809, MAD-810): the box's files come from the +# syncphony-box package at this checkout's version, the nightly update +# timer is on, the box repo is the updater's alone (never in /etc/apt), +# docs and man pages are stripped, and the image is under 1 GiB. set -euo pipefail @@ -101,7 +105,7 @@ fi # --- packages --------------------------------------------------------------- for pkg in cage chromium fonts-noto-color-emoji libgl1-mesa-dri cloud-init avahi-daemon \ pipewire pipewire-bin pipewire-alsa pipewire-pulse wireplumber pulseaudio-utils alsa-utils \ - raspi-utils-core systemd-timesyncd v4l-utils; do + raspi-utils-core systemd-timesyncd v4l-utils syncphony-box; do if sudo awk -v p="${pkg}" ' $0 == "Package: " p { inpkg = 1 } inpkg && /^Status: / { @@ -120,7 +124,7 @@ done for unit in cloud-config.service cloud-final.service syncphony-kiosk.service syncphony-box-config.service \ avahi-daemon.service syncphony-audio.service syncphony-audio-monitor.service boxd.service \ syncphony-data-grow.service syncphony-data-prepare.service syncphony-data-save.service \ - syncphony-data-save.timer systemd-timesyncd.service; do + syncphony-data-save.timer systemd-timesyncd.service syncphony-update.timer; do state="$(systemctl --root="${WORK}/root" is-enabled "${unit}" 2>/dev/null || true)" if [ "${state}" = "enabled" ]; then check "unit ${unit} enabled" ok @@ -248,7 +252,7 @@ else fi if sudo grep -q '^ExecStart=/usr/lib/syncphony-box/boxd serve$' \ - "${WORK}/root/etc/systemd/system/boxd.service"; then + "${WORK}/root/usr/lib/systemd/system/boxd.service"; then check "boxd.service runs boxd serve" ok else check "boxd.service runs boxd serve" "ExecStart mismatch" @@ -256,20 +260,20 @@ fi # --- watchdogs and self-healing (MAD-806) ------------------------------------- if sudo grep -q '^RuntimeWatchdogSec=15s' \ - "${WORK}/root/etc/systemd/system.conf.d/50-syncphony-watchdog.conf"; then + "${WORK}/root/usr/lib/systemd/system.conf.d/50-syncphony-watchdog.conf"; then check "hardware watchdog armed at 15 s" ok else check "hardware watchdog armed at 15 s" "drop-in missing or wrong" fi if sudo grep -q '^RebootWatchdogSec=' \ - "${WORK}/root/etc/systemd/system.conf.d/50-syncphony-watchdog.conf"; then + "${WORK}/root/usr/lib/systemd/system.conf.d/50-syncphony-watchdog.conf"; then check "reboot watchdog bounded" ok else check "reboot watchdog bounded" "no RebootWatchdogSec" fi -if sudo grep -q 'time-sync.target' "${WORK}/root/etc/systemd/system/syncphony-kiosk.service"; then +if sudo grep -q 'time-sync.target' "${WORK}/root/usr/lib/systemd/system/syncphony-kiosk.service"; then check "kiosk unit waits for time sync" ok else check "kiosk unit waits for time sync" "no time-sync.target ordering" @@ -295,22 +299,22 @@ else fi if sudo grep -q 'KERNEL=="uinput", GROUP="cec"' \ - "${WORK}/root/etc/udev/rules.d/60-syncphony-cec.rules" && + "${WORK}/root/usr/lib/udev/rules.d/60-syncphony-cec.rules" && sudo grep -q 'KERNEL=="cec\[0-9\]\*", GROUP="cec"' \ - "${WORK}/root/etc/udev/rules.d/60-syncphony-cec.rules"; then + "${WORK}/root/usr/lib/udev/rules.d/60-syncphony-cec.rules"; then check "udev rule grants cec and uinput to the group" ok else check "udev rule grants cec and uinput to the group" "rule missing or wrong" fi if sudo grep -q '^SupplementaryGroups=cec' \ - "${WORK}/root/etc/systemd/system/boxd.service.d/50-cec.conf"; then + "${WORK}/root/usr/lib/systemd/system/boxd.service.d/50-cec.conf"; then check "boxd holds the cec group" ok else check "boxd holds the cec group" "drop-in missing or wrong" fi -if sudo grep -q '^uinput$' "${WORK}/root/etc/modules-load.d/syncphony-cec.conf"; then +if sudo grep -q '^uinput$' "${WORK}/root/usr/lib/modules-load.d/syncphony-cec.conf"; then check "uinput loaded at boot" ok else check "uinput loaded at boot" "modules-load entry missing" @@ -332,7 +336,7 @@ else fi if sudo grep -q '^After=NetworkManager.service' \ - "${WORK}/root/etc/systemd/system/boxd.service.d/60-network.conf"; then + "${WORK}/root/usr/lib/systemd/system/boxd.service.d/60-network.conf"; then check "boxd starts after NetworkManager" ok else check "boxd starts after NetworkManager" "drop-in missing or wrong" @@ -346,7 +350,7 @@ fi # The boot config apply is boxd now: same unit, new engine. if sudo grep -q '^ExecStart=/usr/lib/syncphony-box/boxd config apply$' \ - "${WORK}/root/etc/systemd/system/syncphony-box-config.service"; then + "${WORK}/root/usr/lib/systemd/system/syncphony-box-config.service"; then check "config apply runs boxd" ok else check "config apply runs boxd" "ExecStart mismatch" @@ -358,7 +362,7 @@ else check "kiosk wrapper enters through boxd" "no boxd URL in the wrapper" fi -if sudo grep -q 'boxd.service' "${WORK}/root/etc/systemd/system/syncphony-kiosk.service"; then +if sudo grep -q 'boxd.service' "${WORK}/root/usr/lib/systemd/system/syncphony-kiosk.service"; then check "kiosk unit waits for boxd" ok else check "kiosk unit waits for boxd" "no boxd reference" @@ -378,7 +382,7 @@ else check "syncphony.txt ships fully commented out" "active lines: ${active}" fi -for key in server_url name audio resolution rotate cec cec_standby cec_standby_minutes; do +for key in server_url name audio resolution rotate cec cec_standby cec_standby_minutes auto_update; do if sudo grep -q "^#${key}=" "${WORK}/boot/syncphony.txt"; then check "syncphony.txt documents ${key}" ok else @@ -414,14 +418,16 @@ for unit in default.target.wants/pipewire.service default.target.wants/wireplumb fi done -if sudo grep -q 'session.suspend-timeout-seconds = 0' \ - "${WORK}/root/var/lib/syncphony-box/.config/wireplumber/wireplumber.conf.d/50-syncphony-box.conf"; then +# A link into the package (absolute, so read the target inside the image). +wp_link="$(sudo readlink "${WORK}/root/var/lib/syncphony-box/.config/wireplumber/wireplumber.conf.d/50-syncphony-box.conf" 2>/dev/null || true)" +if [ "${wp_link}" = "/usr/share/syncphony-box/wireplumber/50-syncphony-box.conf" ] && + sudo grep -q 'session.suspend-timeout-seconds = 0' "${WORK}/root${wp_link}"; then check "wireplumber keeps outputs unsuspended" ok else check "wireplumber keeps outputs unsuspended" "conf missing or rule absent" fi -if sudo grep -q 'syncphony-audio.service' "${WORK}/root/etc/systemd/system/syncphony-kiosk.service"; then +if sudo grep -q 'syncphony-audio.service' "${WORK}/root/usr/lib/systemd/system/syncphony-kiosk.service"; then check "kiosk waits for the audio unit" ok else check "kiosk waits for the audio unit" "no reference in syncphony-kiosk.service" @@ -531,12 +537,80 @@ else fi if sudo grep -q 'SystemMaxUse=50M' \ - "${WORK}/root/etc/systemd/journald.conf.d/50-syncphony-journald.conf"; then + "${WORK}/root/usr/lib/systemd/journald.conf.d/50-syncphony-journald.conf"; then check "journald capped at 50M" ok else check "journald capped at 50M" "drop-in missing or no cap" fi +# --- the syncphony-box package and updates (MAD-810) ------------------------- +# The image installs the box's software as a package; the version must be +# the one this checkout builds (not one pulled from the apt repo). +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +want_version="$("${REPO_ROOT}/scripts/version.sh" --deb 2>/dev/null || true)" +got_version="$(sudo awk ' + $0 == "Package: syncphony-box" { inpkg = 1 } + inpkg && /^Version: / { print $2; exit } +' "${WORK}/root/var/lib/dpkg/status")" +if [ -n "${got_version}" ] && { [ -z "${want_version}" ] || [ "${got_version}" = "${want_version}" ]; }; then + check "syncphony-box package is this build (${got_version})" ok +else + check "syncphony-box package is this build" "installed ${got_version:-nothing}, built ${want_version}" +fi + +if sudo grep -q '^ExecStart=/usr/lib/syncphony-box/boxd update$' \ + "${WORK}/root/usr/lib/systemd/system/syncphony-update.service" && + sudo grep -q '^OnCalendar=\*-\*-\* 04:00:00$' \ + "${WORK}/root/usr/lib/systemd/system/syncphony-update.timer"; then + check "nightly update runs boxd update at 04:00" ok +else + check "nightly update runs boxd update at 04:00" "unit or timer wrong" +fi + +# The box's repo is the updater's alone: never in sources.list.d, where +# the image build's (and a person's) apt runs would use it. +if sudo grep -rqs 'syncphony-box' "${WORK}/root/etc/apt/sources.list.d/" "${WORK}/root/etc/apt/sources.list"; then + check "box repo stays out of the system apt sources" "found in /etc/apt" +else + check "box repo stays out of the system apt sources" ok +fi + +if [ -f "${REPO_ROOT}/packaging/apt/syncphony-box-archive-keyring.asc" ]; then + if sudo test -f "${WORK}/root/usr/share/syncphony-box/apt/syncphony-box.list" && + sudo cmp -s "${REPO_ROOT}/packaging/apt/syncphony-box-archive-keyring.asc" \ + "${WORK}/root/usr/share/keyrings/syncphony-box-archive-keyring.asc"; then + check "updater's apt list and repo key installed" ok + else + check "updater's apt list and repo key installed" "list or keyring missing" + fi +else + check "updater's apt list and repo key installed" ok + echo "NOTE: no repo key in this checkout; the image can't update itself (docs/releasing.md)" +fi + +# --- slim (MAD-809: under ~1 GB compressed) -------------------------------- +if sudo test -f "${WORK}/root/etc/dpkg/dpkg.cfg.d/50-syncphony-slim"; then + check "dpkg keeps docs and translations out" ok +else + check "dpkg keeps docs and translations out" "no 50-syncphony-slim" +fi + +man_files="$(sudo find "${WORK}/root/usr/share/man" -type f 2>/dev/null | wc -l)" +doc_files="$(sudo find "${WORK}/root/usr/share/doc" -type f ! -name copyright 2>/dev/null | wc -l)" +if [ "${man_files}" -eq 0 ] && [ "${doc_files}" -eq 0 ]; then + check "man pages and docs stripped (copyright kept)" ok +else + check "man pages and docs stripped (copyright kept)" "${man_files} man, ${doc_files} doc files left" +fi + +image_bytes="$(stat -c %s "${IMAGE_XZ}")" +limit=$((1024 * 1024 * 1024)) +if [ "${image_bytes}" -le "${limit}" ]; then + check "compressed image under 1 GiB ($((image_bytes / 1024 / 1024)) MiB)" ok +else + check "compressed image under 1 GiB" "$((image_bytes / 1024 / 1024)) MiB" +fi + # --- first user is locked (no password until Imager settings apply) --------- if sudo grep -q '^syncphony:!' "${WORK}/root/etc/shadow"; then check "first user syncphony has locked password" ok diff --git a/scripts/version.sh b/scripts/version.sh new file mode 100755 index 0000000..d055a9d --- /dev/null +++ b/scripts/version.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# The box software's version, from git. One definition for the image name, +# boxd's -X main.version and the .deb (ADR 0011). +# +# scripts/version.sh v1.3.0, v1.3.0-4-gabc1234, v1.3.0-dirty, abc1234 +# scripts/version.sh --deb 1.3.0, 1.3.0+git4.gabc1234, 1.3.0+dirty, 0.0.0+gabc1234 +# +# The Debian form sorts the way the releases do: a build past a tag is +# newer than the tag and older than the next one, so a box never +# "updates" from a dev build back to the release it was cut after. +set -eu + +cd "$(dirname "$0")/.." +v="$(git describe --tags --always --dirty 2>/dev/null || echo unversioned)" + +if [ "${1:-}" != "--deb" ]; then + echo "${v}" + exit 0 +fi + +dirty="" +case "${v}" in +*-dirty) v="${v%-dirty}" dirty=1 ;; +esac + +case "${v}" in +v[0-9]*) + v="${v#v}" + # 1.3.0-4-gabc1234 -> 1.3.0+git4.gabc1234 + deb="$(printf '%s' "${v}" | sed -E 's/-([0-9]+)-(g[0-9a-f]+)$/+git\1.\2/')" + ;; +unversioned) deb="0.0.0" ;; +*) deb="0.0.0+g${v}" ;; # no tag yet: just a commit +esac + +if [ -n "${dirty}" ]; then + case "${deb}" in + *+*) deb="${deb}.dirty" ;; + *) deb="${deb}+dirty" ;; + esac +fi +echo "${deb}" diff --git a/stage-syncphony/00-packages/01-run.sh b/stage-syncphony/00-packages/01-run.sh new file mode 100755 index 0000000..4c97cc8 --- /dev/null +++ b/stage-syncphony/00-packages/01-run.sh @@ -0,0 +1,26 @@ +#!/bin/bash -e + +# The box's own software, as the syncphony-box package (MAD-810, ADR +# 0012): boxd, the kiosk wrapper, the units, the drop-ins, the initramfs +# overlay scripts — everything in packaging/root — plus the postinst that +# creates the kiosk user and the cec group and enables the units. The +# nightly update installs newer builds of this same package from the apt +# repo, so the image and the updates can't drift apart. +# +# Installed here, right after the kiosk stack, so the later substages can +# build on its files (the boot-partition syncphony.txt, the kiosk user's +# home, the initramfs module list). +# +# ./build.sh builds the .deb into files/ (it is not committed). + +if [ ! -f files/syncphony-box.deb ]; then + echo "stage-syncphony/00-packages: files/syncphony-box.deb is missing." >&2 + echo "Build the image with ./build.sh, which builds boxd and the package first." >&2 + exit 1 +fi + +install -v -m 0644 files/syncphony-box.deb "${ROOTFS_DIR}/tmp/syncphony-box.deb" +on_chroot << EOF +apt-get install -y /tmp/syncphony-box.deb +rm -f /tmp/syncphony-box.deb +EOF diff --git a/stage-syncphony/01-kiosk/01-run.sh b/stage-syncphony/01-kiosk/01-run.sh deleted file mode 100755 index 7a8bd4f..0000000 --- a/stage-syncphony/01-kiosk/01-run.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -e - -# The kiosk session: an unprivileged `kiosk` user runs cage + Chromium on -# tty1 under syncphony-kiosk.service. See docs/adr/0002-kiosk-session.md. - -# System user: locked password, no shell, no sudo. Seat-device access comes -# from the logind session (PAMName=login) plus these supplementary groups. -on_chroot << EOF -if ! getent passwd kiosk >/dev/null; then - adduser --system --group --home /var/lib/syncphony-box --shell /usr/sbin/nologin kiosk -fi -for grp in video render audio input; do - adduser kiosk "\$grp" -done -EOF - -install -v -d -m 0755 "${ROOTFS_DIR}/usr/lib/syncphony-box" -install -v -m 0755 files/kiosk "${ROOTFS_DIR}/usr/lib/syncphony-box/kiosk" -install -v -m 0644 files/unconfigured.html "${ROOTFS_DIR}/usr/lib/syncphony-box/unconfigured.html" -install -v -m 0644 files/syncphony-kiosk.service "${ROOTFS_DIR}/etc/systemd/system/syncphony-kiosk.service" - -# The on-box admin command (sudo syncphony-box reset wipes the pairing). -install -v -m 0755 files/syncphony-box "${ROOTFS_DIR}/usr/bin/syncphony-box" - -# /etc/syncphony-box/config.env (written from /boot/firmware/syncphony.txt -# by syncphony-box-config.service) is created by 03-config. - -on_chroot << EOF -install -d -o kiosk -g kiosk -m 0755 /var/lib/syncphony-box -systemctl enable syncphony-kiosk.service -EOF diff --git a/stage-syncphony/03-config/00-run.sh b/stage-syncphony/03-config/00-run.sh index 90ade61..37a7088 100755 --- a/stage-syncphony/03-config/00-run.sh +++ b/stage-syncphony/03-config/00-run.sh @@ -2,16 +2,11 @@ # The boot config file: syncphony.txt ships on the FAT boot partition so # it can be edited from any computer. The parsing and applying live in boxd -# (boxd config apply, run by syncphony-box-config.service); boxd itself is -# installed by 05-boxd. See docs/adr/0003-box-config-file.md and 0005-boxd.md. - -install -v -d -m 0755 "${ROOTFS_DIR}/etc/syncphony-box" -install -v -m 0644 files/syncphony-box-config.service "${ROOTFS_DIR}/etc/systemd/system/syncphony-box-config.service" +# (boxd config apply, run by syncphony-box-config.service); both come with +# the syncphony-box package (00-packages), which also carries the template. +# See docs/adr/0003-box-config-file.md and 0005-boxd.md. # Every option ships commented out; boxd's config apply fills in defaults. # pi-gen's export step copies /boot/firmware onto the FAT partition. -install -v -m 0644 files/syncphony.txt "${ROOTFS_DIR}/boot/firmware/syncphony.txt" - -on_chroot << EOF -systemctl enable syncphony-box-config.service -EOF +install -v -m 0644 "${ROOTFS_DIR}/usr/lib/syncphony-box/syncphony.txt" \ + "${ROOTFS_DIR}/boot/firmware/syncphony.txt" diff --git a/stage-syncphony/04-audio/00-run.sh b/stage-syncphony/04-audio/00-run.sh index e6d280e..d54dee7 100755 --- a/stage-syncphony/04-audio/00-run.sh +++ b/stage-syncphony/04-audio/00-run.sh @@ -3,11 +3,8 @@ # Audio: PipeWire runs for the kiosk user (started at boot via linger, no # login), and audio-setup picks the sink from audio= in syncphony.txt, # sets it to 100% and follows hotplug. See docs/adr/0004-audio-output.md. - -install -v -d -m 0755 "${ROOTFS_DIR}/usr/lib/syncphony-box" -install -v -m 0755 files/audio-setup "${ROOTFS_DIR}/usr/lib/syncphony-box/audio-setup" -install -v -m 0644 files/syncphony-audio.service "${ROOTFS_DIR}/etc/systemd/system/syncphony-audio.service" -install -v -m 0644 files/syncphony-audio-monitor.service "${ROOTFS_DIR}/etc/systemd/system/syncphony-audio-monitor.service" +# audio-setup and its two units come with the syncphony-box package +# (00-packages); what's left here is the kiosk user's own setup. # PipeWire's user services for the kiosk user. Linger makes systemd start # the kiosk user's manager (user@.service) at boot without a login; @@ -27,9 +24,10 @@ done # Keep the outputs from auto-suspending: waking an ALSA device for the # first sound can clip its opening notes (audible between tracks). install -v -d -m 0755 "${ROOTFS_DIR}/var/lib/syncphony-box/.config/wireplumber/wireplumber.conf.d" -install -v -m 0644 files/50-syncphony-box.conf "${ROOTFS_DIR}/var/lib/syncphony-box/.config/wireplumber/wireplumber.conf.d/50-syncphony-box.conf" +# A link to the packaged file, so an update that retunes it reaches the box. +ln -v -sfn /usr/share/syncphony-box/wireplumber/50-syncphony-box.conf \ + "${ROOTFS_DIR}/var/lib/syncphony-box/.config/wireplumber/wireplumber.conf.d/50-syncphony-box.conf" on_chroot << EOF -chown -R kiosk:kiosk /var/lib/syncphony-box/.config -systemctl enable syncphony-audio.service syncphony-audio-monitor.service +chown -R -h kiosk:kiosk /var/lib/syncphony-box/.config EOF diff --git a/stage-syncphony/05-boxd/00-run.sh b/stage-syncphony/05-boxd/00-run.sh deleted file mode 100755 index c1c69a1..0000000 --- a/stage-syncphony/05-boxd/00-run.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -e - -# boxd: the Syncphony box's helper daemon (MAD-803, ADR 0005). It owns -# everything that isn't Chromium: the syncphony.txt config (the boot apply -# runs as syncphony-box-config.service), the box bridge on loopback -# (Syncphony ADR 0016) and the local setup/offline screens the kiosk opens. -# -# The arm64 binary is built by ./build.sh into files/ (it is not committed); -# running pi-gen directly without it is a mistake we catch here. - -if [ ! -f files/boxd ]; then - echo "stage-syncphony/05-boxd: files/boxd is missing." >&2 - echo "Build it first: ./build.sh (or: cd boxd && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o ../stage-syncphony/05-boxd/files/boxd ./cmd/boxd)" >&2 - exit 1 -fi - -install -v -m 0755 files/boxd "${ROOTFS_DIR}/usr/lib/syncphony-box/boxd" -install -v -m 0644 files/boxd.service "${ROOTFS_DIR}/etc/systemd/system/boxd.service" - -on_chroot << EOF -systemctl enable boxd.service -EOF diff --git a/stage-syncphony/06-readonly-root/00-run.sh b/stage-syncphony/06-readonly-root/00-run.sh index ccaa3fa..964fb01 100755 --- a/stage-syncphony/06-readonly-root/00-run.sh +++ b/stage-syncphony/06-readonly-root/00-run.sh @@ -15,14 +15,15 @@ # initramfs script, keyed off its own cmdline token); the stock # `resize` token and rpi-resize (which would grow the read-only root) # come out. - -FILES="$(dirname "$0")/files" +# +# The files — the initramfs overlay and grow-data scripts, data-persist, +# the /data units, the journald cap — come with the syncphony-box package +# (00-packages), which also enables the units. This substage makes the +# image-level changes a package can't own: the kernel command line, +# fstab, the module list, and the paths that become links onto /data. # --- the overlay ------------------------------------------------------------- IIR="${ROOTFS_DIR}/etc/initramfs-tools" -install -v -d -m 0755 "${IIR}/scripts" "${IIR}/scripts/local-premount" -install -v -m 0644 "${FILES}/overlay" "${IIR}/scripts/overlay" -install -v -m 0755 "${FILES}/grow-data" "${IIR}/scripts/local-premount/grow-data" # The overlay module must be in the initramfs (the script also has an # insmod fallback off the real root, but don't rely on it). touch "${IIR}/modules" @@ -93,16 +94,7 @@ for type in rsa ecdsa ed25519; do done done -# --- units and tools ------------------------------------------------------------ -install -v -m 0755 "${FILES}/data-persist" "${ROOTFS_DIR}/usr/lib/syncphony-box/data-persist" -install -v -d -m 0755 "${ROOTFS_DIR}/etc/systemd/journald.conf.d" -install -v -m 0644 "${FILES}/50-syncphony-journald.conf" \ - "${ROOTFS_DIR}/etc/systemd/journald.conf.d/50-syncphony-journald.conf" -for unit in syncphony-data-grow.service syncphony-data-prepare.service \ - syncphony-data-save.service syncphony-data-save.timer; do - install -v -m 0644 "${FILES}/${unit}" "${ROOTFS_DIR}/etc/systemd/system/${unit}" -done - +# --- units ---------------------------------------------------------------------- on_chroot << EOF # The stock first-boot grow targets the root partition; ours targets the # last one. Disable the stock unit (the resize token is already out of the @@ -110,8 +102,8 @@ on_chroot << EOF systemctl disable rpi-resize.service # Package downloads/upgrades onto a tmpfs overlay would vanish at reboot # while looking applied — the one safe update path is writing the real -# root (sudo syncphony-box chroot; ADR 0007), so the timers stay off. +# root, which the box's own nightly update does (syncphony-update.timer +# runs boxd update through syncphony-box chroot; ADR 0012). Debian's +# timers stay off. systemctl disable unattended-upgrades.timer apt-daily.timer apt-daily-upgrade.timer || true -systemctl enable syncphony-data-grow.service syncphony-data-prepare.service \ - syncphony-data-save.service syncphony-data-save.timer EOF diff --git a/stage-syncphony/07-watchdog/00-run.sh b/stage-syncphony/07-watchdog/00-run.sh index 12a592f..ead142d 100755 --- a/stage-syncphony/07-watchdog/00-run.sh +++ b/stage-syncphony/07-watchdog/00-run.sh @@ -4,9 +4,11 @@ # watchdogs are image-level; the kiosk watchdog lives in boxd # (boxd/internal/daemon/watchdog.go). # -# - Hardware watchdog: systemd's RuntimeWatchdogSec=15s (the drop-in -# below) arms the BCM watchdog. A kernel or systemd hang reboots the -# Pi — the one failure no userspace loop can catch. +# - Hardware watchdog: systemd's RuntimeWatchdogSec=15s arms the BCM +# watchdog. The drop-in comes with the syncphony-box package +# (/usr/lib/systemd/system.conf.d/50-syncphony-watchdog.conf). A +# kernel or systemd hang reboots the Pi — the one failure no +# userspace loop can catch. # - Time: systemd-timesyncd, enabled here and ordered before the kiosk # (time-sync.target in syncphony-kiosk.service). The server's # playback clock sync trusts the browser's clock, and a Pi has no RTC @@ -14,12 +16,6 @@ # - Thermals: boxd runs vcgencmd (installed with this stage's packages) # every minute; nothing to wire here. -FILES="$(dirname "$0")/files" - -install -v -d -m 0755 "${ROOTFS_DIR}/etc/systemd/system.conf.d" -install -v -m 0644 "${FILES}/50-syncphony-watchdog.conf" \ - "${ROOTFS_DIR}/etc/systemd/system.conf.d/50-syncphony-watchdog.conf" - on_chroot << EOF systemctl enable systemd-timesyncd.service EOF diff --git a/stage-syncphony/08-cec/00-run.sh b/stage-syncphony/08-cec/00-run.sh deleted file mode 100755 index 95f28b8..0000000 --- a/stage-syncphony/08-cec/00-run.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash -e - -# HDMI-CEC (MAD-807, ADR 0009). The pieces at the image level: -# -# - v4l-utils (00-packages): cec-ctl, boxd's whole CEC interface to the -# kernel framework. -# - The cec group and its udev rule: /dev/cec* and /dev/uinput grant -# access by group, not by root — boxd gets them through -# SupplementaryGroups (the drop-in below). -# - uinput in modules-load.d, so the injected keyboard's device node -# exists before boxd starts. -# -# boxd itself (registering as a playback device, the monitor, the -# wake/switch rate limit, the remote keys, the OK long press) lives in -# boxd/internal/cec. - -FILES="$(dirname "$0")/files" - -on_chroot << EOF -getent group cec >/dev/null || groupadd --system cec -EOF - -install -v -d -m 0755 "${ROOTFS_DIR}/etc/udev/rules.d" -install -v -m 0644 "${FILES}/60-syncphony-cec.rules" \ - "${ROOTFS_DIR}/etc/udev/rules.d/60-syncphony-cec.rules" - -install -v -d -m 0755 "${ROOTFS_DIR}/etc/systemd/system/boxd.service.d" -install -v -m 0644 "${FILES}/50-cec.conf" \ - "${ROOTFS_DIR}/etc/systemd/system/boxd.service.d/50-cec.conf" - -install -v -d -m 0755 "${ROOTFS_DIR}/etc/modules-load.d" -install -v -m 0644 "${FILES}/uinput.conf" \ - "${ROOTFS_DIR}/etc/modules-load.d/syncphony-cec.conf" diff --git a/stage-syncphony/09-wifi-setup/00-run.sh b/stage-syncphony/09-wifi-setup/00-run.sh index 5d05b5e..8a79e99 100755 --- a/stage-syncphony/09-wifi-setup/00-run.sh +++ b/stage-syncphony/09-wifi-setup/00-run.sh @@ -7,25 +7,16 @@ # - NetworkManager + dnsmasq-base (00-packages): the access point runs # in NetworkManager's shared mode, which serves DHCP and DNS through # dnsmasq. -# - The dnsmasq-shared.d drop-in: on that network every name resolves -# to the box and DHCP names the portal — the captive portal. -# - boxd ordered after NetworkManager. +# - The dnsmasq-shared.d drop-in (on that network every name resolves +# to the box and DHCP names the portal — the captive portal) and the +# boxd drop-in ordering it after NetworkManager both come with the +# syncphony-box package. # # boxd itself (when to start the hotspot, the portal form, the join, the # TV screen) lives in boxd/internal/daemon/hotspot.go and # boxd/internal/wifi. The access point's connection is created in memory # only, so it never lands in system-connections — or on /data. -FILES="$(dirname "$0")/files" - -install -v -d -m 0755 "${ROOTFS_DIR}/etc/NetworkManager/dnsmasq-shared.d" -install -v -m 0644 "${FILES}/syncphony-captive.conf" \ - "${ROOTFS_DIR}/etc/NetworkManager/dnsmasq-shared.d/syncphony-captive.conf" - -install -v -d -m 0755 "${ROOTFS_DIR}/etc/systemd/system/boxd.service.d" -install -v -m 0644 "${FILES}/60-network.conf" \ - "${ROOTFS_DIR}/etc/systemd/system/boxd.service.d/60-network.conf" - on_chroot << EOF systemctl enable NetworkManager.service EOF diff --git a/stage-syncphony/10-slim/00-run.sh b/stage-syncphony/10-slim/00-run.sh new file mode 100755 index 0000000..0fc78ca --- /dev/null +++ b/stage-syncphony/10-slim/00-run.sh @@ -0,0 +1,35 @@ +#!/bin/bash -e + +# Keep the image small (MAD-809: under about 1 GB compressed — the +# smoke test enforces it). The syncphony-box package ships a dpkg +# path-exclude drop-in (/etc/dpkg/dpkg.cfg.d/50-syncphony-slim) so +# nothing installed after it — later substages, nightly updates — brings +# documentation, man pages or translations. This substage removes what +# stage0-2 installed before it, following the same rules: the copyright +# files and the English locale stay. + +R="${ROOTFS_DIR}" + +# Documentation, except the copyright files. +find "${R}/usr/share/doc" -mindepth 1 ! -type d ! -name copyright -delete 2>/dev/null || true +find "${R}/usr/share/doc" -mindepth 1 -type d -empty -delete 2>/dev/null || true + +rm -rf "${R}/usr/share/man/"* "${R}/usr/share/info/"* "${R}/usr/share/lintian/"* + +# Translations, except English and the alias table. +find "${R}/usr/share/locale" -mindepth 1 -maxdepth 1 \ + ! -name 'en' ! -name 'en_US' ! -name 'locale.alias' \ + -exec rm -rf {} + 2>/dev/null || true + +# Chromium's UI strings: the box never shows Chromium's own UI, and its +# en-US pack is the fallback for every other locale. +if [ -d "${R}/usr/lib/chromium/locales" ]; then + find "${R}/usr/lib/chromium/locales" -type f ! -name 'en-US.pak*' -delete +fi + +# Nothing reads the apt list cache offline; the nightly update refreshes +# it before every upgrade. +rm -rf "${R}/var/lib/apt/lists/"* +on_chroot << EOF +apt-get clean +EOF diff --git a/tests/syncphony-box-audio.bats b/tests/syncphony-box-audio.bats index 9a5e936..56f46d7 100644 --- a/tests/syncphony-box-audio.bats +++ b/tests/syncphony-box-audio.bats @@ -1,5 +1,5 @@ # Tests for the audio sink selection installed from -# stage-syncphony/04-audio/files/audio-setup. Like the config tests, they +# packaging/root/usr/lib/syncphony-box/audio-setup. Like the config tests, they # run the script straight from the repo, against a fake pactl fed with # representative `pactl list sinks` listings: the vc4 driver names its HDMI # PCM devices "bcm2835 HDMI 1" (HDMI0) and "bcm2835 HDMI 2" (HDMI1) on both @@ -8,7 +8,7 @@ # README smoke checklist; CI has no hardware. BATS_TEST_DIRNAME_ABS="$(cd "${BATS_TEST_DIRNAME}" && pwd)" -SCRIPT="${BATS_TEST_DIRNAME_ABS}/../stage-syncphony/04-audio/files/audio-setup" +SCRIPT="${BATS_TEST_DIRNAME_ABS}/../packaging/root/usr/lib/syncphony-box/audio-setup" setup() { WORK="$(mktemp -d)" diff --git a/tests/syncphony-box-config.bats b/tests/syncphony-box-config.bats index 0d2dee9..3f9e664 100644 --- a/tests/syncphony-box-config.bats +++ b/tests/syncphony-box-config.bats @@ -9,7 +9,7 @@ BATS_TEST_DIRNAME_ABS="$(cd "${BATS_TEST_DIRNAME}" && pwd)" SCRIPT="${BOXD_BIN:-${BATS_TEST_DIRNAME_ABS}/../.build/boxd}" -TEMPLATE="${BATS_TEST_DIRNAME_ABS}/../stage-syncphony/03-config/files/syncphony.txt" +TEMPLATE="${BATS_TEST_DIRNAME_ABS}/../packaging/root/usr/lib/syncphony-box/syncphony.txt" setup() { WORK="$(mktemp -d)" @@ -53,6 +53,7 @@ cmdline_is() { echo "$output" | grep -Fxq "CEC='on'" echo "$output" | grep -Fxq "CEC_STANDBY='off'" echo "$output" | grep -Fxq "CEC_STANDBY_MINUTES=30" + echo "$output" | grep -Fxq "AUTO_UPDATE='on'" } @test "the shipped template is fully commented out (all defaults)" { diff --git a/tests/syncphony-box-data.bats b/tests/syncphony-box-data.bats index 0236365..99c3194 100644 --- a/tests/syncphony-box-data.bats +++ b/tests/syncphony-box-data.bats @@ -5,7 +5,7 @@ # mode: no mount check, no ssh-keygen). BATS_TEST_DIRNAME_ABS="$(cd "${BATS_TEST_DIRNAME}" && pwd)" -SCRIPT="${BATS_TEST_DIRNAME_ABS}/../stage-syncphony/06-readonly-root/files/data-persist" +SCRIPT="${BATS_TEST_DIRNAME_ABS}/../packaging/root/usr/lib/syncphony-box/data-persist" setup() { WORK="$(mktemp -d)" @@ -53,6 +53,30 @@ fake_identity() { grep -q '^syncphony:' "${ETC}/shadow" } +@test "restore keeps accounts an update added to the image (saved entries win)" { + fake_identity + run "${SCRIPT}" save + [ "$status" -eq 0 ] + + # The next boot after an update: the real root's files have the old + # accounts plus a new system user and group the update created, and + # the image's own (stale) line for the renamed Imager user. + printf 'root:x:0:0:root:/root:/bin/bash\nsyncphony:x:1000:1000::/home/syncphony:/usr/sbin/nologin\n_newsvc:x:990:990::/nonexistent:/usr/sbin/nologin\n' > "${ETC}/passwd" + printf 'root:x:0:\n_newsvc:x:990:\n' > "${ETC}/group" + chmod 0640 "${ETC}/shadow" + run "${SCRIPT}" restore + [ "$status" -eq 0 ] + # Saved entries win... + grep -qx 'syncphony:x:1000:1000::/home/syncphony:/bin/bash' "${ETC}/passwd" + # ...the update's new accounts stay... + grep -q '^_newsvc:x:990:' "${ETC}/passwd" + grep -q '^_newsvc:x:990:' "${ETC}/group" + # ...nothing is doubled, and the file keeps its mode. + [ "$(grep -c '^syncphony:' "${ETC}/passwd")" -eq 1 ] + [ "$(grep -c '^root:' "${ETC}/passwd")" -eq 1 ] + [ "$(stat -c %a "${ETC}/shadow")" = "640" ] +} + @test "save then restore round-trips the SSH host keys" { mkdir -p "${DATA}/system/ssh" printf 'PRIVATE-KEY-DATA' > "${ETC}/ssh/ssh_host_ed25519_key"