From 2190dad2ad78e36d894c9b9a435e2e721aa877f8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 4 Jul 2026 16:39:01 +0000 Subject: [PATCH] feat(packaging/bazzite): systemd-sysext replaces rpm-ostree layering as the primary install path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layering is a last resort per the Bazzite docs (slows every OS update, can block upgrades until removed); a sysext never enters an rpm-ostree transaction, survives OS updates, and installs/updates with no reboot — the mechanism Fedora Atomic ships via fedora-sysexts. - build-sysext.sh wraps the built host+web RPMs into punktfunk--x86-64.raw: /etc payload relocated to /usr/share/punktfunk/etc (a sysext carries only /usr), the punktfunk-sysext helper embedded, ID=fedora + VERSION_ID pinned (merges on Bazzite via ID_LIKE; REFUSED after a major rebase instead of running soname-broken binaries — both behaviors validated live on Bazzite 43). SELinux labels are baked in as squashfs pseudo-xattrs from matchpathcon: unlabeled files run fine for user units but system daemons are DENIED (udev couldn't read the gamepad rule under enforcing) — validated on-glass. Refuses duplicate input package names (a stale noarch punktfunk-web next to the x86_64 one built a chimera image with the dead node launcher once). - punktfunk-sysext.sh: install/update/status/remove against per-Fedora-major feeds (…/generic/punktfunk-sysext/f43[-canary]), SHA-256-verified, applies the udev/sysctl scriptlet work + /etc copies, prints the layering-migration hint. Live-validated on the .41 Bazzite box incl. service restart + web console. - publish-sysext-feed.sh + rpm.yml: build + publish the image per matrix leg (fedver 43/44), canary feeds pruned to 6, stable release assets attached. - update-punktfunk.sh warns when the sysext shadows a layered install. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/rpm.yml | 28 ++++ docs-site/content/docs/bazzite.md | 59 ++++--- packaging/README.md | 33 +++- packaging/bazzite/README.md | 116 ++++++++----- packaging/bazzite/build-sysext.sh | 115 +++++++++++++ packaging/bazzite/publish-sysext-feed.sh | 51 ++++++ packaging/bazzite/punktfunk-sysext.sh | 204 +++++++++++++++++++++++ packaging/bazzite/update-punktfunk.sh | 8 + 8 files changed, 539 insertions(+), 75 deletions(-) create mode 100644 packaging/bazzite/build-sysext.sh create mode 100644 packaging/bazzite/publish-sysext-feed.sh create mode 100644 packaging/bazzite/punktfunk-sysext.sh diff --git a/.gitea/workflows/rpm.yml b/.gitea/workflows/rpm.yml index 6b1bf69..7ffe441 100644 --- a/.gitea/workflows/rpm.yml +++ b/.gitea/workflows/rpm.yml @@ -35,8 +35,10 @@ jobs: include: - image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base group: bazzite + fedver: 43 - image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin group: fedora-44 + fedver: 44 container: image: git.unom.io/unom/${{ matrix.image }}:latest timeout-minutes: 90 @@ -53,6 +55,8 @@ jobs: run: | git config --global --add safe.directory "$PWD" dnf -y install gtk4-devel libadwaita-devel SDL3-devel + # sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling. + dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted # bun builds the punktfunk-web console (--with web). Baked into the image; install it # here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note). command -v bun >/dev/null || { @@ -117,6 +121,27 @@ jobs: done echo "published to $OWNER/rpm/$GROUP" + # The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext + # image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that + # `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree. + - name: Build the sysext image + run: | + bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \ + --out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \ + dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \ + dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm + + - name: Publish the sysext feed + env: + TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + case "$GROUP" in + *-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up + *) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release + esac + KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \ + "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" + # On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases # (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep # both on the release; canary builds live in the `*-canary` rpm groups (no release page). @@ -132,3 +157,6 @@ jobs: base="$(basename "$rpm" .rpm)" upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm" done + for raw in dist-sysext/*.raw; do + upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw" + done diff --git a/docs-site/content/docs/bazzite.md b/docs-site/content/docs/bazzite.md index 94b4e22..7e28ce2 100644 --- a/docs-site/content/docs/bazzite.md +++ b/docs-site/content/docs/bazzite.md @@ -24,36 +24,43 @@ mid-stream. You flip between Gaming Mode and Desktop with Bazzite's normal Steam ## Install -The host ships as an RPM in punktfunk's **Gitea RPM registry** (public), so a Bazzite / Fedora -Atomic box layers and updates it with `rpm-ostree`. Add the repo, then layer the host plus the web -console and reboot: +The host installs as a **systemd system extension (sysext)** — no `rpm-ostree` layering. The +Bazzite docs treat layering as a last resort (layered packages slow every OS update and can block +upgrades until removed); a sysext never enters an rpm-ostree transaction: it overlays `/usr` +read-only from `/var/lib/extensions/`, survives OS updates, installs and updates **without a +reboot**, and is removable in one command. This is the same mechanism the Fedora Atomic +maintainers ship via the [fedora-sysexts](https://fedora-sysexts.github.io/) project. ```sh -# Add the repo. Packages are GPG-signed (gpgcheck=1, the packages@unom.io key) AND the repo -# metadata is Gitea-signed (repo_gpgcheck=1); gpgkey lists both keys so dnf imports each. -sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO' -[gitea-unom-bazzite] -name=punktfunk (unom, Bazzite) -baseurl=https://git.unom.io/api/packages/unom/rpm/bazzite -enabled=1 -gpgcheck=1 -repo_gpgcheck=1 -gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key - https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk -REPO - -# Layer the host + the web console, then reboot into the new deployment. -# (punktfunk Recommends punktfunk-web; list it explicitly so it's pulled regardless of weak-dep -# settings — the Gitea registry carries punktfunk-web, which COPR can't build.) -rpm-ostree install punktfunk punktfunk-web -systemctl reboot +# One-time bootstrap (afterwards the updater is on PATH as `punktfunk-sysext`): +curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh +sudo bash punktfunk-sysext.sh install # add `--channel canary` for rolling builds ``` -`rpm-ostree upgrade` then tracks new builds automatically (Bazzite's auto-update timer does this -for you). For a fully baked appliance image there's also a **bootc** Containerfile that installs -the same RPMs from this registry — see `packaging/bootc/` and `packaging/rpm/README.md` in the repo. -Building from source works too (Bazzite is Fedora Atomic underneath, and its FFmpeg builds the host -fine — same steps as [Fedora KDE](/docs/fedora-kde)), but the registry is the supported path. +That downloads the newest image (host + tray + web console, SHA-256-verified over HTTPS from +punktfunk's package registry), merges it, and applies the udev/sysctl setup on the spot — the +host is usable immediately, no reboot. From then on: + +```sh +sudo punktfunk-sysext update # fetch + merge the newest build +sudo punktfunk-sysext status # channel, installed vs latest version +sudo punktfunk-sysext remove # unmerge and delete — the box is back to stock +``` + +Two things to know: + +- **After a Bazzite major rebase** (Fedora 43 → 44) the old image **refuses to load** rather than + run against mismatched system libraries — run `sudo punktfunk-sysext update` once and it fetches + the image built for the new base. +- **Already layering punktfunk?** Install the sysext (it shadows the layered copy immediately), + then drop the layer so it stops slowing your updates: + `sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`. + +For a fully baked appliance image there's also a **bootc** Containerfile that installs the RPMs +from the registry at image-build time — see `packaging/bootc/` in the repo. Plain `rpm-ostree` +layering from the [RPM registry](https://git.unom.io/unom/-/packages) keeps working too (see +`packaging/bazzite/README.md`), but the sysext is the supported default. Building from source +also works (Bazzite is Fedora Atomic underneath — same steps as [Fedora KDE](/docs/fedora-kde)). ## Allow controller input diff --git a/packaging/README.md b/packaging/README.md index 70b6141..efe78bf 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -17,13 +17,15 @@ packaging/ rpm/punktfunk.spec # the RPM (builds punktfunk-host from source with cargo) bazzite/host.env # gamescope-default config for a Bazzite appliance bazzite/README.md # step-by-step Bazzite setup guide + bazzite/*sysext*.sh # the no-layering path: build/install/publish the systemd-sysext bootc/Containerfile # bake punktfunk into a Bazzite-based atomic image copr/ # COPR build-from-SCM settings ``` The other packaging targets have their own READMEs: [`debian/`](debian/README.md) (apt), -[`arch/`](arch/README.md) (PKGBUILD + sysext), [`flatpak/`](flatpak/README.md) (the client), -[`windows/`](windows/README.md) (host installer + drivers), plus `kde/` and `linux/` helpers. +[`arch/`](arch/README.md) (pacman binary repo + PKGBUILD + SteamOS sysext), +[`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer + +drivers), plus `kde/` and `linux/` helpers. ## What's needed beyond base Fedora @@ -38,7 +40,22 @@ On **Bazzite** the only genuinely new runtime bits are `ffmpeg-libs` (RPM Fusion `libei` — the rest of the stack is already there. The default backend is **gamescope** (`packaging/bazzite/host.env`), which the host spawns headless per session — no desktop login. -## Option A — Gitea RPM registry (recommended; per-host, `rpm-ostree`) +## Option A — systemd-sysext (recommended; no layering, no reboot) + +On Bazzite / Fedora Atomic the recommended install is the **systemd-sysext** image — rpm-ostree +layering is a last resort per the Bazzite docs (it slows every OS update and can block upgrades), +while a sysext overlays `/usr` at runtime, survives OS updates, and updates in one command with +no reboot. CI wraps the same RPMs below into the image, so content and channels are identical. + +```sh +curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh +sudo bash punktfunk-sysext.sh install # then: sudo punktfunk-sysext update | status | remove +``` + +Full walkthrough (incl. the F43→F44 rebase behavior and migration off layering): +[`bazzite/README.md`](bazzite/README.md). + +## Option B — Gitea RPM registry (per-host, `rpm-ostree` layering) The host's RPM is published to **unom's self-hosted Gitea RPM registry** (CI builds it on every push), mirroring the [Debian/apt](debian/README.md) setup. Add one repo file, install, and track @@ -60,7 +77,7 @@ rpm-ostree install punktfunk && systemctl reboot # updates: rpm-ostree upgrade && systemctl reboot ``` -## Option B — COPR (per-host, `rpm-ostree install`) +## Option C — COPR (per-host, `rpm-ostree install`) 1. Create a COPR project, enable **build-from-SCM** pointing at this repo, spec path `packaging/rpm/punktfunk.spec` (see `copr/README.md`). Under *External Repositories* add @@ -78,7 +95,7 @@ rpm-ostree install punktfunk && systemctl reboot systemctl reboot ``` -## Option C — bootc (image-based, atomic) +## Option D — bootc (image-based, atomic) Layer punktfunk into a Bazzite image once, then rebase any number of hosts onto it — no per-host drift. See `bootc/Containerfile`: @@ -89,7 +106,7 @@ podman push ghcr.io//bazzite-punktfunk sudo bootc switch ghcr.io//bazzite-punktfunk && systemctl reboot ``` -## First-run setup (either option) +## First-run setup (all options) ```sh ujust add-user-to-input-group # virtual gamepads need /dev/uinput (then re-login). @@ -109,8 +126,8 @@ web console at `https://:47992` or directly. > ⚠️ **COPR caveat:** COPR's mock chroot has no `bun`, so a COPR build produces only > `punktfunk` + `punktfunk-client` — **not** `punktfunk-web`. For the console on a COPR/bootc host, -> install from the **Gitea RPM registry** (Option A — it carries `punktfunk-web`), which is also why -> `bootc/Containerfile` installs from there rather than COPR. +> install from the **Gitea RPM registry** (Option B — it carries `punktfunk-web`; the sysext image +> includes it too), which is also why `bootc/Containerfile` installs from there rather than COPR. ## Why not Flatpak (for the HOST)? diff --git a/packaging/bazzite/README.md b/packaging/bazzite/README.md index cce0681..f189df2 100644 --- a/packaging/bazzite/README.md +++ b/packaging/bazzite/README.md @@ -12,34 +12,91 @@ flagged explicitly. For the higher-level packaging rationale ("why not Flatpak", > NVENC, from RPM Fusion **nonfree**), `opus`, and `libei`. > Source: `packaging/README.md`, `packaging/rpm/punktfunk.spec`. -> ⚠️ **Read this first — the COPR is operator-run, not yet published.** -> Both install paths below pull the punktfunk RPM from a COPR project named -> `enricobuehler/punktfunk`. That COPR is a configuration the maintainer has to **create and -> build** (see `packaging/copr/README.md` — it documents how to set it up, not a live repo URL you -> can assume exists). If `rpm-ostree install punktfunk` 404s, the COPR hasn't been published yet, -> and your only path is to **build the RPM yourself** (see the appendix). The guide flags every -> command that depends on the COPR being live. +> ⚠️ **COPR note (Path C only).** The legacy layering path's commands reference a COPR project +> named `enricobuehler/punktfunk` that is operator-run and may not be published (see +> `packaging/copr/README.md`); layer from the **Gitea RPM registry** instead (`../rpm/README.md`, +> the repo file `https://git.unom.io/api/packages/unom/rpm/bazzite.repo`) — it's what CI +> actually publishes to. Paths A (sysext) and B (bootc) don't involve the COPR at all. --- ## 1. Choose an install path -There are two supported paths on Bazzite, driven by different files in `packaging/`: +There are three paths on Bazzite, driven by different files in `packaging/`: | Path | Driven by | What it does | Best for | |---|---|---|---| -| **A — rpm-ostree layering** | `packaging/copr/README.md` + `packaging/rpm/punktfunk.spec` | Layers the `punktfunk` RPM onto your existing Bazzite deployment with `rpm-ostree install` | One host, quick iteration | +| **A — systemd-sysext** ✅ recommended | `packaging/bazzite/punktfunk-sysext.sh` + `build-sysext.sh` (published by `.gitea/workflows/rpm.yml`) | Overlays the host onto `/usr` as a system extension — no layering, no reboot, one-command updates | Everyone; the default | | **B — bootc / OCI image** | `packaging/bootc/Containerfile` | Bakes punktfunk into a `FROM bazzite-nvidia` image once; you `bootc switch` any number of hosts onto it | Fleets, reproducible appliances, no per-host drift | +| **C — rpm-ostree layering** (legacy) | `packaging/rpm/` + the Gitea RPM registry | Layers the `punktfunk` RPM onto your deployment with `rpm-ostree install` | Only if you specifically want the RPM database to own the files | -**Trade-off:** Path A is a per-host package layer — simple, but each host accumulates its own -layered-package state. Path B builds one image (RPM Fusion + the Gitea RPM repo + the host and -**web console** + udev rule pre-installed) that you push to a registry and rebase hosts onto -atomically — no per-host `rpm-ostree install` drift, at the cost of running a `podman build`/`push` -pipeline. Both require the **same first-run setup** (sections 3–6); note Path B installs from the -**Gitea RPM registry** (which carries `punktfunk-web`), whereas Path A's COPR builds host+client -only — for the web console on Path A, layer from the Gitea registry instead (`../rpm/README.md`). +**Why A over C:** the Bazzite docs treat layering as a last resort — every layered package makes +every OS update slower and can **block upgrades entirely** until removed. A sysext never enters an +rpm-ostree transaction: it merges/unmerges at runtime, survives OS updates, and updating punktfunk +is one command with **no reboot** (layering needs one per update). It's the mechanism the Fedora +Atomic maintainers ship via [fedora-sysexts](https://fedora-sysexts.github.io/). All paths require +the **same first-run setup** (sections 3–6). -### Path A — rpm-ostree layering from the COPR +### Path A — systemd-sysext (recommended) + +Run on the Bazzite host: + +```sh +# One-time bootstrap; afterwards the tool is on PATH as `punktfunk-sysext` (it ships inside +# the image). `--channel canary` for rolling main-branch builds instead of releases. +curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh +sudo bash punktfunk-sysext.sh install +``` + +This downloads the newest image for your Fedora base (host + tray + **web console**, +SHA-256-verified from the feed `…/packages/unom/generic/punktfunk-sysext/f[-canary]/`), +installs it as `/var/lib/extensions/punktfunk.raw`, merges it, and immediately applies what the +RPM scriptlets would have (udev reload, sysctl) plus the two `/etc` files a sysext can't carry +(the gamescope-session drop-in and the tray autostart entry, staged under +`/usr/share/punktfunk/etc/`). No reboot at any point. Day-2: + +```sh +sudo punktfunk-sysext update # fetch + merge the newest build (then restart the user service) +sudo punktfunk-sysext status # merged?, installed vs latest, channel/feed +sudo punktfunk-sysext remove # unmerge + delete; ~/.config/punktfunk is left alone +``` + +Details worth knowing: + +- The image embeds `ID=fedora` + `VERSION_ID` (matched through Bazzite's `ID_LIKE`), so after a + **major Bazzite rebase** (F43 → F44) the old image is **refused** instead of merging + soname-broken binaries — `punktfunk-sysext update` then fetches the image built for the new + base (feeds exist per Fedora major, from the same CI matrix as the RPM groups). +- SELinux labels are baked into the image at build time (squashfs pseudo-xattrs computed from + the targeted policy) — without them udev couldn't read the gamepad rule under enforcing. + Validated live on Bazzite 43. +- **Migrating from layering (path C):** install the sysext (it shadows the layered copy at + once), then `sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`. + +### Path B — bootc image (`FROM bazzite-nvidia`) + +The image is built **off-host** (on any machine with `podman`) from +`packaging/bootc/Containerfile`, which bases on `ghcr.io/ublue-os/bazzite-nvidia:stable` +(override with `--build-arg BASE_IMAGE=…`), enables RPM Fusion free + nonfree, adds the Gitea RPM +repo (`--build-arg PUNKTFUNK_RPM_GROUP=…`, default `bazzite`), and installs the host **and the web +console** (`punktfunk punktfunk-web`). It uses the Gitea registry rather than the COPR specifically +because the registry carries `punktfunk-web` (COPR's mock chroot can't build it — no `bun`). + +```sh +# Build + push (run from the repo root, on your builder machine): +podman build -t ghcr.io//bazzite-punktfunk -f packaging/bootc/Containerfile . +podman push ghcr.io//bazzite-punktfunk + +# On each target Bazzite host: +sudo bootc switch ghcr.io//bazzite-punktfunk && systemctl reboot +``` + +> ⚠️ The image installs from the **Gitea RPM registry** (group `bazzite`), so **Path B depends on +> that registry being populated** — CI (`.gitea/workflows/rpm.yml`) publishes `punktfunk` + +> `punktfunk-web` on every push to `main`. Packages are unsigned with GPG-signed metadata +> (`repo_gpgcheck=1`), matching `packaging/rpm/README.md`. + +### Path C — rpm-ostree layering (legacy) Run on the Bazzite host. (Commands verbatim from `packaging/README.md`.) @@ -62,7 +119,7 @@ systemctl reboot > The **reboot is mandatory** — `rpm-ostree install` stages a new deployment that only takes > effect on the next boot. This is normal atomic-distro behavior, not a punktfunk quirk. -#### Updating a Path-A host — `rpm-ostree upgrade` is NOT enough +#### Updating a Path-C host — `rpm-ostree upgrade` is NOT enough > ⚠️ **`rpm-ostree upgrade` will not update punktfunk on its own.** `upgrade` bumps the **base > image** and only re-resolves *layered* packages **when the base changes**. A Bazzite base can @@ -94,29 +151,6 @@ sudo bash packaging/bazzite/update-punktfunk.sh --reboot # stage + reboot now > `punktfunk.repo`, canary's `.0-0.ciN` **outranks** the stable `X.Y.Z-1` and the box > silently tracks canary. Enable exactly one channel — set `enabled=0` in the other repo file. -### Path B — bootc image (`FROM bazzite-nvidia`) - -The image is built **off-host** (on any machine with `podman`) from -`packaging/bootc/Containerfile`, which bases on `ghcr.io/ublue-os/bazzite-nvidia:stable` -(override with `--build-arg BASE_IMAGE=…`), enables RPM Fusion free + nonfree, adds the Gitea RPM -repo (`--build-arg PUNKTFUNK_RPM_GROUP=…`, default `bazzite`), and installs the host **and the web -console** (`punktfunk punktfunk-web`). It uses the Gitea registry rather than the COPR specifically -because the registry carries `punktfunk-web` (COPR's mock chroot can't build it — no `bun`). - -```sh -# Build + push (run from the repo root, on your builder machine): -podman build -t ghcr.io//bazzite-punktfunk -f packaging/bootc/Containerfile . -podman push ghcr.io//bazzite-punktfunk - -# On each target Bazzite host: -sudo bootc switch ghcr.io//bazzite-punktfunk && systemctl reboot -``` - -> ⚠️ The image installs from the **Gitea RPM registry** (group `bazzite`), so **Path B depends on -> that registry being populated** — CI (`.gitea/workflows/rpm.yml`) publishes `punktfunk` + -> `punktfunk-web` on every push to `main`. Packages are unsigned with GPG-signed metadata -> (`repo_gpgcheck=1`), matching `packaging/rpm/README.md`. - --- ## 2. Prerequisites — what Bazzite gives you vs. what you must still do diff --git a/packaging/bazzite/build-sysext.sh b/packaging/bazzite/build-sysext.sh new file mode 100644 index 0000000..b2ac27a --- /dev/null +++ b/packaging/bazzite/build-sysext.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Build the punktfunk systemd-sysext image for Bazzite / Fedora Atomic from the built RPMs — +# the no-layering install path (rpm-ostree layering slows every update and can block upgrades; +# a sysext never enters an rpm-ostree transaction). The .raw overlays /usr read-only from +# /var/lib/extensions/, survives OS updates, and is toggled/updated without a reboot. +# +# Counterpart to ../arch/build-sysext.sh (which wraps a pacman package for SteamOS). This one +# wraps the Fedora RPMs (punktfunk + punktfunk-web) and additionally: +# * relocates the RPMs' /etc payload to /usr/share/punktfunk/etc/ (a sysext carries ONLY /usr; +# punktfunk-sysext(8) copies these into the real /etc on install), +# * bakes SELinux labels in as squashfs pseudo-xattrs, computed with matchpathcon from the +# build container's targeted policy. Without them every file is unlabeled_t at runtime: +# fine for the user session + systemd --user units (unconfined), but system daemons are +# DENIED — udev couldn't read 60-punktfunk.rules and systemd-sysctl couldn't read the +# sysctl drop-in (validated live on Bazzite 43, SELinux enforcing, 2026-07-04), +# * pins compatibility via ID=fedora + VERSION_ID: merges on Bazzite/Silverblue/Aurora of the +# SAME Fedora major (ID_LIKE matching, systemd >= 256) and is REFUSED after a major rebase +# instead of running soname-broken binaries (`punktfunk-sysext update` then re-resolves), +# * embeds the punktfunk-sysext helper so an installed box can update itself. +# +# Build in the matching Fedora container (ci/fedora*-rpm.Dockerfile) — matchpathcon needs the +# Fedora targeted policy (libselinux-utils + selinux-policy-targeted), and the RPMs are +# soname-coupled to their base anyway. Needs: rpm2cpio, cpio, mksquashfs (>= 4.6), matchpathcon. +# +# Usage: +# bash build-sysext.sh --version-id 43 --out dist/punktfunk-0.7.1-1-x86-64.raw \ +# dist/punktfunk-0.7.1-1.fc43.x86_64.rpm dist/punktfunk-web-0.7.1-1.fc43.noarch.rpm +# +# The installed image MUST be named punktfunk.raw (the embedded extension-release marker is +# extension-release.punktfunk; systemd-sysext requires marker == image name) — the feed carries +# versioned filenames and punktfunk-sysext installs to the fixed name. +set -euo pipefail + +VERSION_ID="" OUT="" RPMS=() +while [ $# -gt 0 ]; do + case "$1" in + --version-id) VERSION_ID="${2:?}"; shift 2 ;; + --out) OUT="${2:?}"; shift 2 ;; + *) RPMS+=("$1"); shift ;; + esac +done +[ -n "$VERSION_ID" ] || { echo "missing --version-id " >&2; exit 1; } +[ -n "$OUT" ] || { echo "missing --out " >&2; exit 1; } +[ "${#RPMS[@]}" -gt 0 ] || { echo "no RPMs given" >&2; exit 1; } +for tool in rpm2cpio cpio mksquashfs matchpathcon; do + command -v "$tool" >/dev/null || { echo "missing tool: $tool" >&2; exit 1; } +done + +HERE="$(cd "$(dirname "$0")" && pwd)" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT + +# SYSEXT_VERSION_ID from the punktfunk RPM (V-R without the dist tag): what +# `punktfunk-sysext status` reports as the installed version. +PF_VR="" +SEEN_NAMES=" " +for rpm in "${RPMS[@]}"; do + [ -f "$rpm" ] || { echo "no such RPM: $rpm" >&2; exit 1; } + name="$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)" + # Two RPMs of the same NAME (e.g. a stale noarch next to the current x86_64 from a sloppy + # download glob) silently shadow each other's files — refuse instead of building a chimera. + case "$SEEN_NAMES" in *" $name "*) echo "duplicate RPM name '$name' in inputs — pass exactly one RPM per package" >&2; exit 1 ;; esac + SEEN_NAMES="$SEEN_NAMES$name " + if [ "$name" = punktfunk ]; then + PF_VR="$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)" + PF_VR="${PF_VR%.fc*}" + fi + rpm2cpio "$rpm" | ( cd "$STAGE" && cpio -idmu --quiet ) +done +[ -n "$PF_VR" ] || { echo "the punktfunk (host) RPM must be among the inputs" >&2; exit 1; } + +# A sysext carries only /usr. Relocate the RPMs' /etc payload (gamescope-session drop-in, tray +# autostart entry) under /usr/share/punktfunk/etc/ — punktfunk-sysext copies it into /etc. +if [ -d "$STAGE/etc" ]; then + mkdir -p "$STAGE/usr/share/punktfunk/etc" + cp -a "$STAGE/etc/." "$STAGE/usr/share/punktfunk/etc/" + rm -rf "${STAGE:?}/etc" +fi +rm -rf "${STAGE:?}/var" # rpm ghosts etc. — nothing outside /usr may remain + +# Self-update: the helper rides inside the image. +install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext" + +# Compatibility marker. ID=fedora matches Bazzite & friends through os-release ID_LIKE; +# VERSION_ID makes a major-rebased host refuse the old ABI instead of merging it. +install -d "$STAGE/usr/lib/extension-release.d" +cat > "$STAGE/usr/lib/extension-release.d/extension-release.punktfunk" <> means "no specific entry" — skip those (the +# handful of matches all resolve to real contexts for our payload). +PSEUDO="$STAGE.pseudo" +( cd "$STAGE" && find . -mindepth 1 \( -type f -o -type d \) -printf '/%P\n' ) | sort \ + | while IFS= read -r path; do + ctx="$(matchpathcon -n "$path" 2>/dev/null || true)" + case "$ctx" in ''|'<>') continue ;; esac + printf '%s x security.selinux=%s\n' "$path" "$ctx" + done > "$PSEUDO" +[ -s "$PSEUDO" ] || { echo "matchpathcon produced no labels — refusing to build an unlabeled image" >&2; exit 1; } + +rm -f "$OUT"; mkdir -p "$(dirname "$OUT")" +# -xattrs-exclude drops any security.selinux the staging fs already had (would collide with the +# pseudo defs when building on an SELinux host); -all-root because cpio extracted as the CI uid. +mksquashfs "$STAGE" "$OUT" -all-root -noappend -quiet \ + -xattrs-exclude '^security.selinux' -pf "$PSEUDO" +rm -f "$PSEUDO" +echo "built $OUT (punktfunk $PF_VR, fedora $VERSION_ID, $(du -h "$OUT" | cut -f1))" +echo " install on the box: punktfunk-sysext install (or --from-file $OUT)" diff --git a/packaging/bazzite/publish-sysext-feed.sh b/packaging/bazzite/publish-sysext-feed.sh new file mode 100644 index 0000000..f405b6e --- /dev/null +++ b/packaging/bazzite/publish-sysext-feed.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Publish a punktfunk sysext image into its feed on the Gitea generic package registry — +# called by .gitea/workflows/rpm.yml after the RPM publish. A feed is one fixed URL +# (…/punktfunk-sysext//) holding versioned .raw files plus a SHA256SUMS manifest; +# punktfunk-sysext(8) on the boxes reads SHA256SUMS to find + verify the newest image +# (the layout is also exactly what systemd-sysupdate's url-file source expects, so a +# .transfer feed can be added later without re-publishing anything). +# +# Usage: TOKEN=… [KEEP=6] bash publish-sysext-feed.sh +# e.g. f43, f43-canary, f44 (Fedora major x channel) +# KEEP newest images to keep in the feed; 0/unset-for-stable = keep all +# Env: REGISTRY (git.unom.io), OWNER (unom), TOKEN (write:package PAT), CURL_USER (login name) +set -euo pipefail + +FEED="${1:?usage: publish-sysext-feed.sh }" +RAW="${2:?usage: publish-sysext-feed.sh }" +[ -f "$RAW" ] || { echo "no such image: $RAW" >&2; exit 1; } +REGISTRY="${REGISTRY:-git.unom.io}" +OWNER="${OWNER:-unom}" +KEEP="${KEEP:-0}" +AUTH=(--user "${CURL_USER:-enricobuehler}:${TOKEN:?TOKEN (write:package PAT) required}") +BASE="https://$REGISTRY/api/packages/$OWNER/generic/punktfunk-sysext/$FEED" + +FNAME="$(basename "$RAW")" +SHA="$(sha256sum "$RAW" | cut -d' ' -f1)" + +# Merge into the existing manifest: drop any prior line for this filename, append ours. +SUMS="$(mktemp)"; trap 'rm -f "$SUMS"' EXIT +curl -fsS "${AUTH[@]}" "$BASE/SHA256SUMS" 2>/dev/null | grep -v " $FNAME\$" > "$SUMS" || true +printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS" + +# Prune: keep only the newest $KEEP images (by version sort) in manifest + registry. +PRUNE=() +if [ "$KEEP" -gt 0 ]; then + mapfile -t PRUNE < <(awk '{print $2}' "$SUMS" | sort -V | head -n -"$KEEP") + for f in "${PRUNE[@]:-}"; do + [ -n "$f" ] && sed -i "\| $f\$|d" "$SUMS" + done +fi + +# Upload order keeps consumers consistent: image first, then the manifest referencing it, +# then prune deletions (already absent from the manifest). Delete-before-put makes workflow +# re-runs idempotent (the registry 409s on duplicate filenames; first-publish 404s are fine). +curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$FNAME" || true +curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$RAW" "$BASE/$FNAME" +curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true +curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS" +for f in "${PRUNE[@]:-}"; do + [ -n "$f" ] && { echo "pruning $f"; curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$f" || true; } +done +echo "published $FNAME -> $BASE ($(wc -l <"$SUMS") image(s) in the feed)" diff --git a/packaging/bazzite/punktfunk-sysext.sh b/packaging/bazzite/punktfunk-sysext.sh new file mode 100644 index 0000000..f29f566 --- /dev/null +++ b/packaging/bazzite/punktfunk-sysext.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# punktfunk-sysext — install/update the punktfunk host on Bazzite / Fedora Atomic as a +# systemd-sysext, the no-layering path (rpm-ostree layering is a last resort per the Bazzite +# docs: it slows every update and can block upgrades; a sysext never enters an rpm-ostree +# transaction, needs no reboot, and is trivially removable). +# +# The image overlays /usr from /var/lib/extensions/punktfunk.raw with the host, tray and web +# console + their udev/sysctl/systemd-user payload; the RPMs' two /etc files (gamescope +# session drop-in, tray autostart) ride inside at /usr/share/punktfunk/etc/ and are copied +# into the real /etc here (a sysext can only carry /usr). +# +# Bootstrap (the script also ships inside the image as /usr/bin/punktfunk-sysext): +# curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh +# sudo bash punktfunk-sysext.sh install # or: install --channel canary +# Thereafter: +# sudo punktfunk-sysext update | status | remove +# +# Feed: the Gitea generic package registry, one feed per Fedora major x channel +# (…/punktfunk-sysext/f43/, f43-canary, f44, …), each a SHA256SUMS + versioned .raw files — +# published by .gitea/workflows/rpm.yml from the same RPMs the (legacy) layering path uses. +# The image pins ID=fedora + VERSION_ID, so after a major OS rebase the old image is refused +# (not merged broken) and `punktfunk-sysext update` re-resolves against the new release. +set -euo pipefail + +REGISTRY="${PUNKTFUNK_SYSEXT_REGISTRY:-https://git.unom.io/api/packages/unom/generic/punktfunk-sysext}" +CONF=/etc/punktfunk-sysext.conf +EXT_DIR=/var/lib/extensions +IMG="$EXT_DIR/punktfunk.raw" +SIDECAR="$EXT_DIR/.punktfunk.version" +MARKER=/usr/lib/extension-release.d/extension-release.punktfunk +ETC_SRC=/usr/share/punktfunk/etc + +usage() { + sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p' + echo "usage: punktfunk-sysext install [--channel stable|canary] [--from-file X.raw]" + echo " punktfunk-sysext update [--from-file X.raw] | status | remove" + exit "${1:-0}" +} +need_root() { [ "$(id -u)" = 0 ] || { echo "run as root (sudo)" >&2; exit 1; }; } + +os_version_id() { . /etc/os-release; echo "${VERSION_ID%%.*}"; } +channel() { # shellcheck disable=SC1090 + [ -f "$CONF" ] && . "$CONF"; echo "${CHANNEL:-stable}"; } +feed_url() { + local suffix="" + [ "$(channel)" = canary ] && suffix="-canary" + echo "$REGISTRY/f$(os_version_id)$suffix" +} + +# latest -> "VERSION FILENAME SHA256" from the feed's SHA256SUMS (highest by version sort). +latest() { + local feed; feed="$(feed_url)" + curl -fsSL "$feed/SHA256SUMS" \ + | awk '$2 ~ /^punktfunk-.*-x86-64\.raw$/ { v=$2; sub(/^punktfunk-/,"",v); sub(/-x86-64\.raw$/,"",v); print v, $2, $1 }' \ + | sort -V | tail -n1 +} + +installed_version() { + if [ -f "$MARKER" ]; then + sed -n 's/^SYSEXT_VERSION_ID=//p' "$MARKER" + elif [ -f "$SIDECAR" ]; then + cat "$SIDECAR" + fi +} +merged() { [ -f "$MARKER" ]; } + +post_merge() { + if ! merged; then + echo "!! image installed but NOT merged — 'systemd-sysext status' / 'journalctl -u systemd-sysext'" >&2 + echo "!! (an OS release the image doesn't match? 'punktfunk-sysext update' fetches the right one)" >&2 + return 1 + fi + # What the RPM scriptlets would have done: pick up the uinput/uhid rule + the UDP buffer + # sysctl now, no reboot (both also auto-apply at boot once merged — the files live in /usr/lib). + udevadm control --reload 2>/dev/null || : + udevadm trigger --subsystem-match=misc 2>/dev/null || : + for f in /usr/lib/sysctl.d/99-punktfunk-net.conf /usr/lib/sysctl.d/99-punktfunk-client-net.conf; do + [ -f "$f" ] && sysctl -q -p "$f" 2>/dev/null || : + done + # The /etc payload a sysext can't carry. The gamescope-session drop-in is %config(noreplace): + # only seed it, never clobber a local edit. The tray autostart entry is not user config. + if [ -f "$ETC_SRC/gamescope-session-plus/sessions.d/steam" ] \ + && [ ! -e /etc/gamescope-session-plus/sessions.d/steam ]; then + install -Dm0644 "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \ + /etc/gamescope-session-plus/sessions.d/steam + fi + if [ -f "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" ]; then + install -Dm0644 "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" \ + /etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop + fi +} + +# do_install VERSION FILENAME SHA256 | do_install --from-file X.raw +do_install() { + need_root + mkdir -p "$EXT_DIR" + local tmp="$EXT_DIR/.punktfunk.raw.new" ver + if [ "$1" = --from-file ]; then + ver="(local: $(basename "$2"))" + cp -f "$2" "$tmp" + else + ver="$1" + echo "downloading punktfunk $ver ($(channel), fedora $(os_version_id))…" + curl -fL --progress-bar -o "$tmp" "$(feed_url)/$2" + echo "$3 $tmp" | sha256sum -c --quiet + fi + mv -f "$tmp" "$IMG" # marker inside is extension-release.punktfunk — name must match + echo "$ver" > "$SIDECAR" + systemctl enable --now systemd-sysext.service >/dev/null 2>&1 || : + systemd-sysext refresh + post_merge + echo "punktfunk $ver merged into /usr." +} + +layering_hint() { + if command -v rpm-ostree >/dev/null 2>&1 \ + && rpm-ostree status 2>/dev/null | grep -q 'LayeredPackages:.*punktfunk'; then + cat >&2 <<'EOF' +!! punktfunk is ALSO layered via rpm-ostree. The sysext now shadows it, but remove the +!! layer so it stops slowing/blocking OS updates (the reason this sysext exists): +!! sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot +EOF + fi +} + +cmd_install() { + need_root + local from_file="" + while [ $# -gt 0 ]; do + case "$1" in + --channel) printf 'CHANNEL=%s\n' "${2:?}" > "$CONF"; shift 2 ;; + --from-file) from_file="${2:?}"; shift 2 ;; + *) usage 1 ;; + esac + done + if [ -n "$from_file" ]; then + do_install --from-file "$from_file" + else + local l; l="$(latest)" + [ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; } + # shellcheck disable=SC2086 + do_install $l + fi + layering_hint + cat <<'EOF' + +First-run (once): + ujust add-user-to-input-group # virtual gamepads; then log out + back in + mkdir -p ~/.config/punktfunk + cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env + systemctl --user daemon-reload && systemctl --user enable --now punktfunk-host +Updates: sudo punktfunk-sysext update +EOF +} + +cmd_update() { + need_root + if [ "${1:-}" = --from-file ]; then do_install --from-file "${2:?}"; return; fi + local cur l ver + cur="$(installed_version)" + l="$(latest)" + [ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; } + ver="${l%% *}" + if [ "$ver" = "$cur" ] && merged; then + echo "already on $cur (channel $(channel)) — nothing to do." + return + fi + echo "updating: ${cur:-} -> $ver" + # shellcheck disable=SC2086 + do_install $l + echo "restart the host to pick up the new binary: systemctl --user restart punktfunk-host" +} + +cmd_status() { + echo "channel: $(channel)" + echo "feed: $(feed_url)" + echo "image: $([ -f "$IMG" ] && du -h "$IMG" | cut -f1 || echo '(not installed)')" + echo "merged: $(merged && echo yes || echo no)" + echo "installed: $(installed_version || true)" + echo "latest: $(latest 2>/dev/null | cut -d' ' -f1 || true)" +} + +cmd_remove() { + need_root + # /etc cleanup needs the /usr payload for the unmodified-compare — do it BEFORE unmerging. + if merged; then + if cmp -s "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \ + /etc/gamescope-session-plus/sessions.d/steam 2>/dev/null; then + rm -f /etc/gamescope-session-plus/sessions.d/steam + fi + fi + rm -f /etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop + rm -f "$IMG" "$SIDECAR" "$CONF" + systemd-sysext refresh 2>/dev/null || : + echo "punktfunk sysext removed (user config in ~/.config/punktfunk is untouched)." +} + +case "${1:-}" in + install) shift; cmd_install "$@" ;; + update) shift; cmd_update "$@" ;; + status) shift; cmd_status ;; + remove) shift; cmd_remove ;; + *) usage ;; +esac diff --git a/packaging/bazzite/update-punktfunk.sh b/packaging/bazzite/update-punktfunk.sh index 18d4b88..a82f97a 100755 --- a/packaging/bazzite/update-punktfunk.sh +++ b/packaging/bazzite/update-punktfunk.sh @@ -23,6 +23,14 @@ if [[ $EUID -ne 0 ]]; then exit 1 fi +# The sysext path (packaging/bazzite/punktfunk-sysext.sh) supersedes layering entirely — if the +# box runs the sysext, it shadows any layered copy and THIS script won't change what executes. +if [[ -f /var/lib/extensions/punktfunk.raw ]]; then + echo "NOTE: the punktfunk sysext is installed — update with 'punktfunk-sysext update' instead." >&2 + echo " (a layered punktfunk is shadowed by the sysext; consider removing the layer:" >&2 + echo " rpm-ostree uninstall punktfunk punktfunk-web)" >&2 +fi + # Which punktfunk packages are actually layered right now (host, web, or both). mapfile -t layered < <(rpm-ostree status --json 2>/dev/null \ | grep -oE '"punktfunk(-web)?"' | tr -d '"' | sort -u)