diff --git a/docs/README.md b/docs/README.md index 581b34f..47066b9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,7 +30,7 @@ Docs are split by the question they answer: - [0015 — Propose a post-cutoff bootc-only installer scope (Proposed)](adr/0015-bootc-only-installer-scope.md) - [0016 — Version-2 bootc-only recipe contract (Accepted)](adr/0016-bootc-only-recipe-contract.md) - [0017 — Publish Debian packages through frostyard/apt-publisher](adr/0017-publish-debian-packages-through-apt-publisher.md) -- [0018 — Read each image's core flatpak set from an OCI label (Proposed)](adr/0018-image-published-core-flatpaks-label.md) +- [0018 — Read each image's core flatpak set from an OCI label](adr/0018-image-published-core-flatpaks-label.md) ### Design @@ -40,6 +40,7 @@ Docs are split by the question they answer: - [Firn recipe schema (version 2; temporary bootc-v1 compatibility)](specs/recipe-schema.md) - [Firn progress protocol (version 1)](specs/progress-protocol.md) +- [Core Flatpaks image label (version 1)](specs/core-flatpaks-label.md) ### Plans diff --git a/docs/adr/0006-install-time-offline-first-flatpaks.md b/docs/adr/0006-install-time-offline-first-flatpaks.md index 54afce5..d22ccd3 100644 --- a/docs/adr/0006-install-time-offline-first-flatpaks.md +++ b/docs/adr/0006-install-time-offline-first-flatpaks.md @@ -1,6 +1,6 @@ # 0006 — Install-time, offline-first flatpak provisioning on both paths -- **Status:** Accepted +- **Status:** Accepted; core-set source superseded by [0018](0018-image-published-core-flatpaks-label.md) - **Date:** 2026-08-11 ## Context diff --git a/docs/adr/0018-image-published-core-flatpaks-label.md b/docs/adr/0018-image-published-core-flatpaks-label.md index 076befa..1abdc0c 100644 --- a/docs/adr/0018-image-published-core-flatpaks-label.md +++ b/docs/adr/0018-image-published-core-flatpaks-label.md @@ -1,6 +1,6 @@ # 0018 — Read each image's core flatpak set from an OCI label -- **Status:** Proposed +- **Status:** Accepted - **Date:** 2026-10-06 ## Context @@ -147,9 +147,12 @@ mechanism. When accepted, ADR-0006 gains a status note pointing here. ISO still puts its GNOME seed on any image it installs. That is existing behavior, unchanged here. Installing only the requested apps from a medium is a separate problem with its own decision. -- **Fail before writing.** A malformed label with `core_flatpaks = true` - stops the install before any disk write. The TUI never offers the toggle - for such an image, so only headless recipes can reach that error. +- **Fail before writing.** A malformed or unreadable label with + `core_flatpaks = true` stops the install before any disk write. The TUI + does not offer the toggle for an image whose preview shows a malformed + label, but a wizard recipe can still reach the error: when the preview's + inspection failed and the toggle stayed, or when the tag moved between the + preview and the install. Headless recipes can reach it directly. - **Saved recipes change meaning.** A saved Sundog or Floe recipe with `core_flatpaks = true` installs that image's set, or none, instead of the GNOME list. The release notes say so. @@ -180,8 +183,10 @@ mechanism. When accepted, ADR-0006 gains a status note pointing here. - Shapes: [design/architecture.md](../design/architecture.md) (system configuration, flatpaks), [specs/recipe-schema.md](../specs/recipe-schema.md) (`core_flatpaks`), [specs/progress-protocol.md](../specs/progress-protocol.md) - (`no_core_set`), a new core-flatpaks label spec created with the - implementation, [roadmap Phase 10](../plans/roadmap.md#phase-10) + (`no_core_set`, `core_flatpaks_label_invalid`, + `core_flatpaks_label_unreadable`), + [specs/core-flatpaks-label.md](../specs/core-flatpaks-label.md) (the label + contract), [roadmap Phase 10](../plans/roadmap.md#phase-10) - Builds on: [ADR-0006](0006-install-time-offline-first-flatpaks.md) (offline-first provisioning, whose core-set source this replaces), [ADR-0010](0010-single-installer-iso-in-snosi.md) (snosi owns the ISO and diff --git a/docs/design/architecture.md b/docs/design/architecture.md index a71d814..abb5cd8 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -12,7 +12,8 @@ Living document. Rationale: boundary: firn ships binary + kiosk unit + contracts; snosi ships the single installer ISO). Contracts: [specs/recipe-schema.md](../specs/recipe-schema.md), -[specs/progress-protocol.md](../specs/progress-protocol.md). +[specs/progress-protocol.md](../specs/progress-protocol.md), +[specs/core-flatpaks-label.md](../specs/core-flatpaks-label.md). ## Current bootc-only recipe boundary @@ -64,6 +65,16 @@ headless-only; the wizard instead accepts pasted keys and creates its own private password files. The exact parity/delta table lives in the [recipe schema](../specs/recipe-schema.md#interactive-wizard-parity). +The flatpaks page inspects the chosen image's +[core Flatpaks label](../specs/core-flatpaks-label.md) when it opens, behind a +progress spinner with a 30-second timeout, using preflight's local-first selection without signature +verification, and again whenever the chosen image changes. It lists a +published set's app names beside the toggle; explains instead of offering +the toggle when the image publishes no set or a malformed one (and clears any +earlier answer); and keeps the toggle with an "available at install time" +note when inspection fails. The preview never enters the recipe: preflight +reads the label again at install. + Each interactive run owns a randomly named 0700 directory below `/run/firn`. The reviewed recipe references only 0600 secret files in that directory, and the wizard's canonical serializer returns the accepted review bytes directly @@ -184,13 +195,14 @@ and record delimiters before the writer runs. Flatpaks follow ADR-0006: copy from the medium's seeded repo, download the remainder into the mounted target, report (never silently drop) what was unreachable. With `core_flatpaks`, the core set -comes from first-setup's `core.json` in the deployment, or, when composefs -makes that unreadable ([ADR-0012](../adr/0012-bootc-install-from-ram-installer.md)), -from the ISO's `/usr/share/firn/core-flatpaks.json`; neither depends on the -image being installed. -[ADR-0018 (Proposed)](../adr/0018-image-published-core-flatpaks-label.md) -replaces both with an `org.frostyard.core-flatpaks` label each image -publishes ([roadmap Phase 10](../plans/roadmap.md#phase-10)). +comes from the selected image's `org.frostyard.core-flatpaks` label +([ADR-0018](../adr/0018-image-published-core-flatpaks-label.md), +[spec](../specs/core-flatpaks-label.md)). `preflight-image` reads it from +the same inspection that selects the source, before any disk write: a +malformed label fails the install there, and an image with no set is +reported as `no_core_set`. The flatpak step installs the recipe's explicit +apps, then the core set, each once. Firn no longer reads first-setup's +`core.json` or the ISO's `/usr/share/firn/core-flatpaks.json`. ### Trust @@ -262,9 +274,10 @@ uses the same engine preflight as headless installation. [ADR-0012](../adr/0012-bootc-install-from-ram-installer.md) - Scope and contract: [ADR-0015 (Proposed)](../adr/0015-bootc-only-installer-scope.md), [ADR-0016 (Accepted)](../adr/0016-bootc-only-recipe-contract.md), - [ADR-0018 (Proposed)](../adr/0018-image-published-core-flatpaks-label.md), + [ADR-0018](../adr/0018-image-published-core-flatpaks-label.md), [roadmap Phase 9](../plans/roadmap.md#phase-9-proposed-post-cutoff-bootc-only-transition-bounded-cross-repo-not-started) - Contracts: [specs/recipe-schema.md](../specs/recipe-schema.md), - [specs/progress-protocol.md](../specs/progress-protocol.md) + [specs/progress-protocol.md](../specs/progress-protocol.md), + [specs/core-flatpaks-label.md](../specs/core-flatpaks-label.md) - Release and distribution: [ADR-0017](../adr/0017-publish-debian-packages-through-apt-publisher.md) - Built in: [roadmap — Phases 1–7](../plans/roadmap.md) diff --git a/docs/plans/roadmap.md b/docs/plans/roadmap.md index 182868f..9972837 100644 --- a/docs/plans/roadmap.md +++ b/docs/plans/roadmap.md @@ -275,8 +275,10 @@ this phase does not rewrite its historical A/B evidence or mark it done. unsupported overrides wholesale and fall back to bootc-only built-ins. Remaining rollout work includes inventory and migration of stored v1 recipes, release-date/expiry recording, and coordination with snosi on - its shipped catalog and ISO/tool payload. Sundog's ref/key and Snowfield's - scope need confirmation; Sundog is not in `builtinCatalog()`. Keep Firn's media boundary from + its shipped catalog and ISO/tool payload. Snowfield's scope needs + confirmation. Sundog is offered on the ISO through snosi's shipped + `/etc/firn/catalog.json`, which carries its ref and cosign key; it is not + in `builtinCatalog()`, which applies only without that file. Keep Firn's media boundary from [ADR-0010](../adr/0010-single-installer-iso-in-snosi.md) and UEFI floor from [ADR-0004](../adr/0004-single-installer-scope-and-support-matrix.md). 2. **Qualification and sole-path decision gate:** after the above changes, @@ -288,7 +290,7 @@ this phase does not rewrite its historical A/B evidence or mark it done. | --- | --- | --- | | Snow | Secure Boot on + TPM present + `tpm2-luks-passphrase`; Secure Boot off + TPM absent + `none` | Published image ref and cosign key; published snosi ISO build; device identity; install-to-login and hostname/user/config checks. | | Floe | Secure Boot off + TPM absent + `luks-passphrase` | Same provenance, device, login and configuration checks; verify boot-time passphrase unlock. | - | Sundog | Confirm image/key/catalog first; if secure-capable, Secure Boot on + TPM present + `tpm2-luks` | Same provenance, device, login and configuration checks; if not secure-capable, explicitly reallocate the Secure Boot cell to a supported image. | + | Sundog | Image, key and catalog entry ship in snosi's catalog; if secure-capable, Secure Boot on + TPM present + `tpm2-luks` | Same provenance, device, login and configuration checks; if not secure-capable, explicitly reallocate the Secure Boot cell to a supported image. | | Snowfield | Decide whether in the post-cutoff catalog/support scope before qualification | If included, assign a supported distinct hardware cell and record the same provenance, ISO, login and configuration evidence; otherwise record exclusion and rationale. | For each qualified cell, capture the exact published image digest/ref, @@ -312,48 +314,49 @@ this phase does not rewrite its historical A/B evidence or mark it done. decision has been made. **Not complete; implementation in progress.** -## Phase 10 — Image-published core flatpaks (small, cross-repo) — 📝 proposed +## Phase 10 — Image-published core flatpaks (small, cross-repo) — ⏳ in progress -**Proposed.** [ADR-0018 (Proposed)](../adr/0018-image-published-core-flatpaks-label.md) +**In progress.** [ADR-0018](../adr/0018-image-published-core-flatpaks-label.md) moves the `core_flatpaks` set from first-setup's `core.json` and the ISO's `/usr/share/firn/core-flatpaks.json` to an `org.frostyard.core-flatpaks` -label each image publishes. Today every image, including Sundog and Floe, -gets first-setup's GNOME list when the user opts in. Order matters: snosi -labels ship before firn stops reading the old paths. +label each image publishes ([spec](../specs/core-flatpaks-label.md)). +Before it, every image, including Sundog and Floe, got first-setup's GNOME +list when the user opted in. Order matters: snosi labels ship before a firn +release stops reading the old paths. Out of scope: offline flatpak seeding. Published ISOs carry no seed, and a locally seeded ISO keeps today's whole-tree copy (ADR-0018 Consequences). Migrating existing installs and removing Sundog's native apps belong to snosi. -1. **Settle the label contract (firn docs).** ADR-0018, still Proposed, - defines the parser cases, preflight-time validation, and which image the - set describes. -2. **Snosi: publish the label.** Add `flatpaks/snow.json` and - `flatpaks/sundog.json` (Snowfield uses `snow.json`; Floe has none) and - validate them in CI. Pass the label at every `buildah-package.sh` call - site. Generate first-setup's `core.json` on Snow from `snow.json` with a - drift check. CI checks the label on the final pushed images and its - absence on Floe. -3. **Firn: consume the label.** Accept ADR-0018 and write the label spec - (new `docs/specs/` entry) with this code. `preflight-image` returns the - selected source's labels, parses them when `core_flatpaks = true`, and - keeps the set for `runFlatpaks` in `internal/steps/bootc.go`; - `internal/flatpak` replaces `CoreSet` / `InstallerCoreSet` with the - parser. Update the `core_flatpaks` row in the - [recipe schema](../specs/recipe-schema.md), the - [progress protocol](../specs/progress-protocol.md), and - [architecture](../design/architecture.md) in the same change, and correct - the Phase 9 Sundog note (Sundog is offered through snosi's - `/etc/firn/catalog.json`). Tag only after step 2's images are published. -4. **Firn TUI.** Inspect the chosen image with a timeout and again on image - change; distinguish inspect failure, no core set, malformed label and a - valid set; hiding the toggle clears it. Unit-test navigation and failure - states, and re-sync `test/e2e-tui.sh` per the drive-tui-e2e skill against - a fixture rather than live registry labels. +1. ✅ **Settle the label contract (firn docs).** ADR-0018 defines the parser + cases, preflight-time validation, and which image the set describes + (#110). +2. ✅ **Snosi: publish the label** (frostyard/snosi#1054). + `flatpaks/snow.json` (Snow and Snowfield) and `flatpaks/sundog.json`; + Floe has none. CI validates the files, every packaging lane passes the + label and checks it, and the secure lane checks the pushed digest. The + ISO fallback and seed read `flatpaks/legacy/firn-core-flatpaks.json`, + generated from `snow.json`; first-setup never read its own `core.json`. +3. ✅ **Firn: consume the label.** ADR-0018 accepted; the + [label spec](../specs/core-flatpaks-label.md) lands with the code. + `preflight-image` returns the selected source's labels, parses them when + `core_flatpaks = true` (malformed fails with + `core_flatpaks_label_invalid`), and keeps the set for `runFlatpaks`, + which installs explicit apps then the core set, each once. + `internal/flatpak` no longer reads first-setup's `core.json` or the ISO + fallback. Tag only after step 2's images are published. +4. ✅ **Firn TUI.** The flatpaks page inspects the chosen image with a + timeout and again on image change, and distinguishes inspect failure, no + core set, a malformed label and a valid set; hiding the toggle clears it. + Unit tests cover image switching and every state. `test/e2e-tui.sh` + asserts Floe's real outcome (no toggle, an explanation); the guest + already pulls Floe from GHCR, so this adds no new registry dependency. 5. **Snosi: retire the fallback.** Once the ISO carries a step-3 firn - release, remove `/usr/share/firn/core-flatpaks.json` from - `shared/firn-installer/mkosi.conf` and the Justfile's `_firn-binary`. + release, remove the `/usr/share/firn/core-flatpaks.json` embedding from + `shared/firn-installer/mkosi.conf` and the Justfile's `_firn-binary`, + and point the seed at `flatpaks/snow.json` so + `flatpaks/legacy/firn-core-flatpaks.json` can go. - **Done when:** Snow and Sundog installs with `core_flatpaks = true` each land their own image's set, a malformed label fails preflight before any diff --git a/docs/specs/core-flatpaks-label.md b/docs/specs/core-flatpaks-label.md new file mode 100644 index 0000000..251b109 --- /dev/null +++ b/docs/specs/core-flatpaks-label.md @@ -0,0 +1,102 @@ +# Spec: Core Flatpaks image label (version 1) + +This contract governs the `org.frostyard.core-flatpaks` OCI image label: the +core Flatpak set an image publishes for firn to install when a recipe sets +`core_flatpaks = true`. Snosi produces it from `flatpaks/` in every image +build; firn's `preflight-image` step consumes it (`internal/flatpak` +`ParseCoreLabel`, `internal/steps/preflight.go`). It replaces first-setup's +`core.json` and the installer ISO's `/usr/share/firn/core-flatpaks.json` as +the source of the core set ([ADR-0018](../adr/0018-image-published-core-flatpaks-label.md)). + +## Interface + +The label value is a single JSON object: + +```json +{"version":1,"flatpaks":[{"id":"org.mozilla.firefox","name":"Firefox"}]} +``` + +| Field | Type | Required | Constraints | +| --- | --- | --- | --- | +| `version` | integer | yes | Exactly `1`. | +| `flatpaks` | array of objects | yes | May be empty (no core set). | +| `flatpaks[].id` | string | yes | Flatpak application ID: at least three dot-separated elements of `[A-Za-z0-9_-]`, none starting with a digit, `-` only in the last element, at most 255 characters. | +| `flatpaks[].name` | string | yes | Display name; not empty or whitespace only, and printable: no control or format characters (newlines, tabs, escape sequences, zero-width joiners). Plain spaces and non-ASCII letters are allowed. | + +Field names are exact and case-sensitive. An image with no core set +publishes no label key. + +## Rules + +1. Firn parses the label fail-closed. Every row below other than "No core + set" and "Accepted" is **malformed**: + + | Label content | Result | + | --- | --- | + | Key absent | No core set | + | Empty string | Malformed | + | `null`, or not a single JSON object (invalid JSON, trailing data) | Malformed | + | `version` missing, null, not an integer, or not `1` (`1.0` and `true` are not `1`) | Malformed | + | `flatpaks` missing or null | Malformed | + | Any field other than `version`, `flatpaks`, and per entry `id`, `name`, including case variants such as `VERSION` or `Id` | Malformed | + | An entry that is null, or whose `id` is missing, empty, or not a valid application ID | Malformed | + | `name` missing, empty, or whitespace only | Malformed | + | `name` containing a non-printable character (control or format character) | Malformed | + | `flatpaks` is an empty array | No core set | + | Duplicate `id` | Accepted; later duplicates dropped, first occurrence kept | + +2. Firn reads the label in `preflight-image`, before any disk write, from + the image that step selects: the local containers-storage copy when it is + selected, otherwise the registry image. For a signed recipe that is the + cosign-verified digest firn deploys. For an unsigned recipe bootc resolves + the tag again when it pulls, so a tag that moves during the install can + deploy a newer build than the label described + ([firn#109](https://github.com/frostyard/firn/issues/109)). +3. With `core_flatpaks = true`: + - a malformed label fails `preflight-image` with error code + `core_flatpaks_label_invalid`, in dry-run and real runs alike; + - labels that could not be read fail `preflight-image` with error code + `core_flatpaks_label_unreadable`. A signed `@sha256:` reference can + verify while its registry inspection failed and no matching local copy + exists; its labels are then unknown, not absent, and the install stops + rather than report `no_core_set`; + - no core set emits the `no_core_set` warning and summary item, and the + install continues; + - otherwise the set's IDs are installed after the recipe's explicit + `flatpaks`, each ID once at its first occurrence, through ADR-0006's + offline-first provisioning. +4. With `core_flatpaks = false`, firn does not parse the label; a malformed + label cannot affect the install. +5. A new format increments `version`. Firn releases that do not know a + version treat its label as malformed. +6. Producers must not publish an empty `flatpaks` array or an empty-string + value; an image without a core set carries no label key. +7. The TUI wizard previews the label on its flatpaks page, inspecting the + chosen image with rule 2's local-first selection (no signature + verification) under a 30-second timeout, behind a progress spinner that + swallows keys typed meanwhile, and again when the chosen image changes. + The local copy is inspected before the registry, so a hanging registry + cannot hide a local image. It: + - lists a valid set's names beside the `core_flatpaks` toggle; + - shows no toggle, and an explanation, for no core set or a malformed + label, and sets `core_flatpaks` to false; + - keeps the toggle, noting the list is read at install time, when + inspection fails or returns no readable metadata, and inspects again on + the next visit. + + The preview is display-only and never written into the recipe. + +## Derived artifacts + +| Artifact | Derivation | +| --- | --- | +| Snosi `flatpaks/*.json` and `flatpaks/core-flatpaks.py` | Produce the label per product and check it on every packaged and pushed image; snosi's `test/core-flatpaks-test.sh` mirrors rule 1's rejections. | +| Progress codes `no_core_set`, `core_flatpaks_label_invalid`, `core_flatpaks_label_unreadable` | [Progress protocol](progress-protocol.md#stable-codes). | +| `core_flatpaks` recipe field | [Recipe schema](recipe-schema.md). | + +## References + +- Rationale: [ADR-0018](../adr/0018-image-published-core-flatpaks-label.md), + [ADR-0006](../adr/0006-install-time-offline-first-flatpaks.md) +- Context: [design/architecture.md](../design/architecture.md) (system + configuration, flatpaks) diff --git a/docs/specs/progress-protocol.md b/docs/specs/progress-protocol.md index 73ac368..6d2ca53 100644 --- a/docs/specs/progress-protocol.md +++ b/docs/specs/progress-protocol.md @@ -66,11 +66,13 @@ version-1 wire contract. | Code | Event surfaces | Meaning | | --- | --- | --- | | `cleanup_failed` | `warning` | A registered pipeline cleanup failed; the terminal event still follows and the run is unsuccessful. | +| `core_flatpaks_label_invalid` | `error` | Core Flatpaks were requested and the selected image's `org.frostyard.core-flatpaks` label is malformed ([core Flatpaks label](core-flatpaks-label.md)); raised in `preflight-image`, so no destructive step has run. | +| `core_flatpaks_label_unreadable` | `error` | Core Flatpaks were requested but the selected image's labels could not be read (its inspection failed although the image was otherwise resolved); raised in `preflight-image`, so no destructive step has run. Check registry access and retry. | | `flatpak_unreachable` | `warning`, `summary` | A requested Flatpak could not be reached and was not installed. | | `group_missing` | `warning`, `summary` | A requested supplementary user group was absent from the installed image. | | `image_verification_failed` | `error` | Bootc image digest resolution or cosign verification failed in `preflight-image`; no destructive step has run. | | `image_verification_retried` | `warning` | A cosign verification attempt in `preflight-image` failed (transient registry responses can do this) and a bounded retry follows; verification is not weakened, and the run still fails closed with `image_verification_failed` if the final attempt fails. | -| `no_core_set` | `warning`, `summary` | Core Flatpaks were requested but the selected image publishes no core set. | +| `no_core_set` | `warning`, `summary` | Core Flatpaks were requested but the selected image publishes no core set (no label, or an empty one); emitted in `preflight-image` and the install continues. | | `no_tpm` | `warning` | No TPM was detected; only choices that do not require it remain valid. | | `recipe_v1_deprecated` | `warning` | Accepted bootc version-1 recipe; message is the `deprecated-version` text in the [recipe schema](recipe-schema.md#bootc-version-1-compatibility). Emitted by install and the TUI before the first `step_start`. `firn validate` writes the deprecation to stderr instead of emitting progress and exits 0. | | `step_failed` | `error` | A pipeline step failed without a more specific stable code. | @@ -110,5 +112,9 @@ contain the key. ## References - Rationale: [ADR-0007](../adr/0007-tui-only-frontend-single-binary.md), - [ADR-0016](../adr/0016-bootc-only-recipe-contract.md) + [ADR-0016](../adr/0016-bootc-only-recipe-contract.md), + [ADR-0018](../adr/0018-image-published-core-flatpaks-label.md) + (`no_core_set`, `core_flatpaks_label_invalid`, + `core_flatpaks_label_unreadable`; see + [core Flatpaks label](core-flatpaks-label.md)) - Context: [design/architecture.md](../design/architecture.md) diff --git a/docs/specs/recipe-schema.md b/docs/specs/recipe-schema.md index fa5b71e..d9e9c46 100644 --- a/docs/specs/recipe-schema.md +++ b/docs/specs/recipe-schema.md @@ -64,11 +64,14 @@ advance past each prompt; the recipe always serializes the accepted values. | `timezone` | string | no | IANA zone name, e.g. `America/Chicago`; MUST exist in the target's zoneinfo. Empty or omitted preserves the image default. | | `keyboard` | string | no | `LAYOUT[:VARIANT[:MODEL]]` XKB triplet. Empty or omitted preserves the image default. | | `flatpaks` | array of string | no | Flatpak application IDs. | -| `core_flatpaks` | bool | no | Install the image-defined core set where published. Default `false`. | +| `core_flatpaks` | bool | no | Install the core set the selected image publishes in its `org.frostyard.core-flatpaks` label ([core Flatpaks label](core-flatpaks-label.md)), after `flatpaks`. A malformed label fails preflight; an image with no set installs none and reports `no_core_set`. Default `false`. | | `root_ssh_authorized_key` / `_file` | string / path | no | At most one. The inline value or referenced file contains one or more newline-separated OpenSSH public-key records; every line MUST be non-empty and match a supported key type, base64 key body, and optional comment. | The TUI initially offers user creation with the `sudo` group, but initially -leaves `core_flatpaks` disabled. Rebuilding either form preserves the user's +leaves `core_flatpaks` disabled. It offers `core_flatpaks` only when the +chosen image publishes a core set or could not be inspected, and never +serializes `core_flatpaks = true` for an image whose toggle it hid +([core Flatpaks label](core-flatpaks-label.md), rule 7). Rebuilding either form preserves the user's accepted values rather than reapplying these initial selections. ### `[system.user]` (optional table; omit to create no user) @@ -189,5 +192,7 @@ fail closed with `bad-version`; no downgrade or conversion is attempted. [ADR-0004](../adr/0004-single-installer-scope-and-support-matrix.md), [ADR-0006](../adr/0006-install-time-offline-first-flatpaks.md), [ADR-0015](../adr/0015-bootc-only-installer-scope.md), - [ADR-0016](../adr/0016-bootc-only-recipe-contract.md) + [ADR-0016](../adr/0016-bootc-only-recipe-contract.md), + [ADR-0018](../adr/0018-image-published-core-flatpaks-label.md) + (`core_flatpaks`; see [core Flatpaks label](core-flatpaks-label.md)) - Context: [design/architecture.md](../design/architecture.md) diff --git a/internal/bootcimg/bootcimg_test.go b/internal/bootcimg/bootcimg_test.go index 1ef9a84..e158525 100644 --- a/internal/bootcimg/bootcimg_test.go +++ b/internal/bootcimg/bootcimg_test.go @@ -499,9 +499,11 @@ func TestCheckImage(t *testing.T) { if !tt.wantErr && err != nil { t.Fatalf("CheckImage() error = %v", err) } + // Local first: a hanging registry must not consume the + // deadline before the local copy is inspected. want := []call{ - {name: "skopeo", args: []string{"inspect", "docker://ghcr.io/tuna-os/yellowfin:gnome-hwe"}}, {name: "skopeo", args: []string{"inspect", "containers-storage:ghcr.io/tuna-os/yellowfin:gnome-hwe"}}, + {name: "skopeo", args: []string{"inspect", "docker://ghcr.io/tuna-os/yellowfin:gnome-hwe"}}, } if len(calls) != len(want) { t.Fatalf("CheckImage() made calls %v, want %v", calls, want) @@ -527,10 +529,10 @@ func TestCheckImage_StripsTransportPrefix(t *testing.T) { if len(calls) != 2 { t.Fatalf("calls = %v", calls) } - if got, want := calls[0].args[1], "docker://ghcr.io/ublue-os/bluefin:stable"; got != want { - t.Errorf("remote ref = %q, want %q", got, want) - } - if got, want := calls[1].args[1], "containers-storage:ghcr.io/ublue-os/bluefin:stable"; got != want { + if got, want := calls[0].args[1], "containers-storage:ghcr.io/ublue-os/bluefin:stable"; got != want { t.Errorf("local ref = %q, want %q", got, want) } + if got, want := calls[1].args[1], "docker://ghcr.io/ublue-os/bluefin:stable"; got != want { + t.Errorf("remote ref = %q, want %q", got, want) + } } diff --git a/internal/bootcimg/verify.go b/internal/bootcimg/verify.go index aedd5ee..e7299d6 100644 --- a/internal/bootcimg/verify.go +++ b/internal/bootcimg/verify.go @@ -31,64 +31,93 @@ const verifyAttempts = 3 var verifyBackoff = []time.Duration{5 * time.Second, 15 * time.Second} type inspectManifest struct { - Digest string `json:"Digest"` + Digest string `json:"Digest"` + Labels map[string]string `json:"Labels"` +} + +// Source is the image preflight selected: the reference bootc installs and +// the config labels of that same image (firn ADR-0018 reads the core +// Flatpak set from them). +type Source struct { + Ref string + Labels map[string]string + // Inspected reports that Labels came from a successful inspection of + // the selected image. When false the labels are unknown, not absent: + // a signed digest can verify while its registry inspection failed. + Inspected bool } // CheckAndPinImage checks that image is reachable or cached. When keyPath is // set, it also resolves the source Firn will actually install to an immutable // digest, verifies that digest with cosign, and returns the pinned reference. -// Resolving before verification closes the tag-movement race. warn, when -// non-nil, receives one message per failed non-final cosign attempt (the -// attempt's stderr is embedded by the runner error). -func CheckAndPinImage(ctx context.Context, r *runner.Runner, image, keyPath string, warn func(string)) (string, error) { +// Resolving before verification closes the tag-movement race. Labels come +// from the inspection of the image selected: the local copy when it wins, +// otherwise the registry image. warn, when non-nil, receives one message per +// failed non-final cosign attempt (the attempt's stderr is embedded by the +// runner error). +func CheckAndPinImage(ctx context.Context, r *runner.Runner, image, keyPath string, warn func(string)) (Source, error) { if keyPath != "" && !IsRegistryRef(image) { - return "", fmt.Errorf("bootcimg: cosign verification requires a registry image reference, got %q", image) + return Source{}, fmt.Errorf("bootcimg: cosign verification requires a registry image reference, got %q", image) } bare := bareImageRef(image) - remoteOut, remoteErr := r.Run(ctx, "skopeo", "inspect", "docker://"+bare) + // Local first: it needs no network, so a registry that hangs until the + // caller's deadline cannot starve the inspection of an image that is + // already here (and would be selected). localOut, localErr := r.Run(ctx, "skopeo", "inspect", "containers-storage:"+bare) + remoteOut, remoteErr := r.Run(ctx, "skopeo", "inspect", "docker://"+bare) + var local, remote inspectManifest + localOK := localErr == nil && json.Unmarshal(localOut, &local) == nil + remoteOK := remoteErr == nil && json.Unmarshal(remoteOut, &remote) == nil if keyPath == "" { - var local inspectManifest - if localErr == nil && json.Unmarshal(localOut, &local) == nil { - return image, nil + // Unsigned sources keep the tag, so bootc resolves it again when it + // pulls; a tag that moves meanwhile can deploy a newer build than + // these labels describe (ADR-0018, firn#109). + if localOK { + return Source{Ref: image, Labels: local.Labels, Inspected: true}, nil } if remoteErr == nil { - return image, nil + return Source{Ref: image, Labels: remote.Labels, Inspected: remoteOK}, nil } - return "", fmt.Errorf("bootcimg: image %q is not reachable in its registry and not present in local containers-storage: %w", image, remoteErr) + return Source{}, fmt.Errorf("bootcimg: image %q is not reachable in its registry and not present in local containers-storage: %w", image, remoteErr) } + var labels map[string]string + inspected := false digest, pinned := digestReference(bare) if !pinned { // Match CheckImage's embedded-image rule: a valid local manifest wins // even if the registry advertises a newer tag. The selected digest is // still verified against the registry signature before installation. - var local inspectManifest - localSelected := localErr == nil && json.Unmarshal(localOut, &local) == nil - if localSelected { + if localOK { digest = local.Digest if !sha256DigestRE.MatchString(digest) { - return "", fmt.Errorf("bootcimg: selected local image %q has no valid sha256 digest", image) + return Source{}, fmt.Errorf("bootcimg: selected local image %q has no valid sha256 digest", image) } + labels, inspected = local.Labels, true } else { digest = manifestDigest(remoteOut, remoteErr) + labels, inspected = remote.Labels, remoteOK } if digest == "" { if remoteErr != nil { - return "", fmt.Errorf("bootcimg: resolving verified digest for %q: %w", image, remoteErr) + return Source{}, fmt.Errorf("bootcimg: resolving verified digest for %q: %w", image, remoteErr) } - return "", fmt.Errorf("bootcimg: resolving verified digest for %q: skopeo returned no valid sha256 digest", image) + return Source{}, fmt.Errorf("bootcimg: resolving verified digest for %q: skopeo returned no valid sha256 digest", image) } bare = repositoryName(bare) + "@" + digest } else if !sha256DigestRE.MatchString(digest) { - return "", fmt.Errorf("bootcimg: invalid immutable digest in image reference %q", image) + return Source{}, fmt.Errorf("bootcimg: invalid immutable digest in image reference %q", image) + } else if localOK && local.Digest == digest { + labels, inspected = local.Labels, true + } else if remoteOK { + labels, inspected = remote.Labels, true } if err := verifyImageSignature(ctx, r, keyPath, bare, warn); err != nil { - return "", err + return Source{}, err } - return bare, nil + return Source{Ref: bare, Labels: labels, Inspected: inspected}, nil } // verifyImageSignature runs cosign verify against the pinned reference, diff --git a/internal/bootcimg/verify_test.go b/internal/bootcimg/verify_test.go index 6999e10..f5fb215 100644 --- a/internal/bootcimg/verify_test.go +++ b/internal/bootcimg/verify_test.go @@ -63,8 +63,8 @@ func TestCheckAndPinImageVerifiesResolvedDigest(t *testing.T) { if err != nil { t.Fatal(err) } - if want := "ghcr.io/frostyard/snow@" + remoteDigest; got != want { - t.Fatalf("pinned source = %q, want %q", got, want) + if want := "ghcr.io/frostyard/snow@" + remoteDigest; got.Ref != want { + t.Fatalf("pinned source = %q, want %q", got.Ref, want) } } @@ -79,8 +79,8 @@ func TestCheckAndPinImageOfflineCachedImage(t *testing.T) { if err != nil { t.Fatal(err) } - if want := "ghcr.io/frostyard/snow@" + localDigest; got != want { - t.Fatalf("offline pinned source = %q, want cached %q", got, want) + if want := "ghcr.io/frostyard/snow@" + localDigest; got.Ref != want { + t.Fatalf("offline pinned source = %q, want cached %q", got.Ref, want) } } @@ -97,8 +97,8 @@ func TestCheckAndPinImageBindsVerificationToSelectedLocalDigest(t *testing.T) { t.Fatal(err) } want := "registry.example.com:5000/snow@" + localDigest - if got != want || verified != want { - t.Fatalf("source = %q, verified = %q, want selected digest %q", got, verified, want) + if got.Ref != want || verified != want { + t.Fatalf("source = %q, verified = %q, want selected digest %q", got.Ref, verified, want) } } @@ -146,8 +146,8 @@ func TestCheckAndPinImageVerificationFailures(t *testing.T) { if err != nil { t.Fatalf("verification after transient failures = %v, want success", err) } - if want := "ghcr.io/frostyard/snow@" + remoteDigest; got != want { - t.Fatalf("pinned source = %q, want %q", got, want) + if want := "ghcr.io/frostyard/snow@" + remoteDigest; got.Ref != want { + t.Fatalf("pinned source = %q, want %q", got.Ref, want) } } else { if err == nil || !strings.Contains(err.Error(), tc.err.Error()) { @@ -214,3 +214,108 @@ func TestCheckAndPinImageRejectsLocalTransportWithVerification(t *testing.T) { t.Fatalf("local transport error = %v", err) } } + +// TestCheckAndPinImageLabelsFollowSelectedImage pins ADR-0018's rule that +// the core Flatpak label is read from the image preflight selects, never +// from the other inspection. +func TestCheckAndPinImageLabelsFollowSelectedImage(t *testing.T) { + inspect := func(digest, set string) []byte { + return []byte(`{"Digest":"` + digest + `","Labels":{"containers.bootc":"1","set":"` + set + `"}}`) + } + notCached := errors.New("not cached") + offline := errors.New("network unreachable") + for _, tc := range []struct { + name string + image string + key string + remoteOut []byte + remoteErr error + localOut []byte + localErr error + wantRef string + wantSet string // "" means no labels + unknown bool // labels could not be read (Inspected false) + }{ + {name: "unsigned, local copy wins over a newer registry image", + image: "ghcr.io/frostyard/snow:latest", + remoteOut: inspect(remoteDigest, "remote"), localOut: inspect(localDigest, "local"), + wantRef: "ghcr.io/frostyard/snow:latest", wantSet: "local"}, + {name: "unsigned, registry when nothing is cached", + image: "ghcr.io/frostyard/snow:latest", + remoteOut: inspect(remoteDigest, "remote"), localErr: notCached, + wantRef: "ghcr.io/frostyard/snow:latest", wantSet: "remote"}, + {name: "signed tag, local copy wins", + image: "ghcr.io/frostyard/snow:latest", key: "/keys/cosign.pub", + remoteOut: inspect(remoteDigest, "remote"), localOut: inspect(localDigest, "local"), + wantRef: "ghcr.io/frostyard/snow@" + localDigest, wantSet: "local"}, + {name: "signed tag, registry when nothing is cached", + image: "ghcr.io/frostyard/snow:latest", key: "/keys/cosign.pub", + remoteOut: inspect(remoteDigest, "remote"), localErr: notCached, + wantRef: "ghcr.io/frostyard/snow@" + remoteDigest, wantSet: "remote"}, + {name: "signed digest, local copy of that digest", + image: "ghcr.io/frostyard/snow@" + localDigest, key: "/keys/cosign.pub", + remoteOut: inspect(localDigest, "remote"), localOut: inspect(localDigest, "local"), + wantRef: "ghcr.io/frostyard/snow@" + localDigest, wantSet: "local"}, + {name: "signed digest, local copy of another digest is ignored", + image: "ghcr.io/frostyard/snow@" + remoteDigest, key: "/keys/cosign.pub", + remoteOut: inspect(remoteDigest, "remote"), localOut: inspect(localDigest, "local"), + wantRef: "ghcr.io/frostyard/snow@" + remoteDigest, wantSet: "remote"}, + {name: "signed digest, offline with no matching local copy: labels unknown", + image: "ghcr.io/frostyard/snow@" + remoteDigest, key: "/keys/cosign.pub", + remoteErr: offline, localErr: notCached, + wantRef: "ghcr.io/frostyard/snow@" + remoteDigest, unknown: true}, + {name: "unsigned, unreadable registry inspection: labels unknown", + image: "ghcr.io/frostyard/snow:latest", + remoteOut: []byte("not json"), localErr: notCached, + wantRef: "ghcr.io/frostyard/snow:latest", unknown: true}, + {name: "inspection without labels", + image: "ghcr.io/frostyard/snow:latest", key: "/keys/cosign.pub", + remoteOut: []byte(`{"Digest":"` + remoteDigest + `"}`), localErr: notCached, + wantRef: "ghcr.io/frostyard/snow@" + remoteDigest}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls [][]string + r := verifyRunner(t, tc.remoteOut, tc.remoteErr, tc.localOut, tc.localErr, nil, &calls) + got, err := CheckAndPinImage(context.Background(), r, tc.image, tc.key, nil) + if err != nil { + t.Fatal(err) + } + if got.Ref != tc.wantRef { + t.Fatalf("source ref = %q, want %q", got.Ref, tc.wantRef) + } + if gotSet := got.Labels["set"]; gotSet != tc.wantSet { + t.Fatalf("labels came from %q, want %q (labels %v)", gotSet, tc.wantSet, got.Labels) + } + if got.Inspected == tc.unknown { + t.Fatalf("Inspected = %v, want %v", got.Inspected, !tc.unknown) + } + }) + } +} + +// A registry that hangs until the caller's deadline must not starve the +// inspection of a local copy, which is the one selected. +func TestCheckAndPinImageLocalInspectSurvivesHangingRegistry(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + r := runner.NewFake( + func(ctx context.Context, name string, args ...string) ([]byte, error) { + if strings.HasPrefix(args[1], "docker://") { + <-ctx.Done() // the registry hangs until the deadline + return nil, ctx.Err() + } + if ctx.Err() != nil { + return nil, ctx.Err() // exec.CommandContext would refuse to start + } + return []byte(`{"Digest":"` + localDigest + `","Labels":{"set":"local"}}`), nil + }, + func(name string) (string, error) { return "/usr/bin/" + name, nil }, + ) + got, err := CheckAndPinImage(ctx, r, "ghcr.io/frostyard/snow:latest", "", nil) + if err != nil { + t.Fatal(err) + } + if !got.Inspected || got.Labels["set"] != "local" { + t.Fatalf("source = %+v, want the local copy's labels", got) + } +} diff --git a/internal/flatpak/flatpak.go b/internal/flatpak/flatpak.go index 7b2ad98..ca34be4 100644 --- a/internal/flatpak/flatpak.go +++ b/internal/flatpak/flatpak.go @@ -11,7 +11,6 @@ package flatpak import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -291,68 +290,3 @@ func dirSize(ctx context.Context, r *runner.Runner, path string) int64 { n, _ := strconv.ParseInt(fields[0], 10, 64) return n } - -// coreJSONPath is where snosi images publish their core flatpak set. -// The file is owned by the frostyard/first-setup repository and ships -// in its package — the single source of truth for the system flatpaks -// firn installs with core_flatpaks (snosi-firstboot line 36 read the -// same file on first boot before firn took the install-time role). -const coreJSONPath = "usr/share/org.frostyard.FirstSetup/snow_first_setup/core.json" - -// InstallerCoreJSONPath is where the single-installer ISO embeds the -// same core flatpak list, read as a fallback when the deployment does -// not expose one. Composefs-native bootc images (every snosi desktop -// image) keep /usr in composefs objects that are not readable as plain -// files at install time, so CoreSet against the deployment root returns -// ok=false and core_flatpaks would install nothing. The ISO build -// (snosi firn-installer) copies first-setup's core.json here so the list -// is always readable. A var so tests can point it elsewhere. -var InstallerCoreJSONPath = "/usr/share/firn/core-flatpaks.json" - -// CoreSet reads the image-defined core flatpak app IDs from a mounted -// image root (bootc deployment root or A/B erofs root). Images that -// publish no readable core set (e.g. server images without -// snow-first-setup, or composefs deployments whose /usr is not -// materialized at install time) return ok=false — the caller falls back -// to InstallerCoreSet, then reports it, per the recipe's core_flatpaks -// contract ("where the image family publishes one"). The list is -// deduplicated: it is human-maintained and has carried duplicates -// (snosi-firstboot's sort -u comment). -func CoreSet(imageRoot string) (ids []string, ok bool, err error) { - return parseCoreJSON(filepath.Join(imageRoot, coreJSONPath)) -} - -// InstallerCoreSet reads the core flatpak list the installer medium -// embedded at InstallerCoreJSONPath, if present. Same result shape as -// CoreSet; a missing file returns ok=false. -func InstallerCoreSet() (ids []string, ok bool, err error) { - return parseCoreJSON(InstallerCoreJSONPath) -} - -// parseCoreJSON reads and deduplicates the core flatpak app IDs from a -// core.json at path. A missing file is not an error (ok=false). -func parseCoreJSON(path string) (ids []string, ok bool, err error) { - data, readErr := os.ReadFile(path) - if readErr != nil { - if os.IsNotExist(readErr) { - return nil, false, nil - } - return nil, false, fmt.Errorf("flatpak: reading core set: %w", readErr) - } - var parsed struct { - Core []struct { - ID string `json:"id"` - } `json:"core"` - } - if err := json.Unmarshal(data, &parsed); err != nil { - return nil, false, fmt.Errorf("flatpak: parsing core.json: %w", err) - } - seen := map[string]bool{} - for _, e := range parsed.Core { - if e.ID != "" && !seen[e.ID] { - seen[e.ID] = true - ids = append(ids, e.ID) - } - } - return ids, true, nil -} diff --git a/internal/flatpak/flatpak_test.go b/internal/flatpak/flatpak_test.go index 391ca1c..62a5071 100644 --- a/internal/flatpak/flatpak_test.go +++ b/internal/flatpak/flatpak_test.go @@ -5,7 +5,6 @@ package flatpak import ( "context" "fmt" - "os" "path/filepath" "slices" "strings" @@ -398,54 +397,3 @@ func TestFlatpakListSkipsHeaderAndBlank(t *testing.T) { t.Errorf("flatpakList = %v", got) } } - -func TestCoreSetReadsAndDedupes(t *testing.T) { - root := t.TempDir() - dir := filepath.Join(root, coreJSONPath) - if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil { - t.Fatal(err) - } - body := `{"core":[{"id":"org.gnome.Console"},{"id":"org.mozilla.firefox"},{"id":"org.gnome.Console"}]}` - if err := os.WriteFile(dir, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - ids, ok, err := CoreSet(root) - if err != nil || !ok { - t.Fatalf("CoreSet ok=%v err=%v", ok, err) - } - if !slices.Equal(ids, []string{"org.gnome.Console", "org.mozilla.firefox"}) { - t.Fatalf("CoreSet ids = %v (want deduped, in order)", ids) - } -} - -func TestCoreSetMissingIsNotAnError(t *testing.T) { - ids, ok, err := CoreSet(t.TempDir()) - if err != nil || ok || ids != nil { - t.Fatalf("CoreSet on empty root: ids=%v ok=%v err=%v", ids, ok, err) - } -} - -// TestInstallerCoreSetFallback covers the composefs case: the deployment -// publishes no readable core set, but the installer medium embeds one. -func TestInstallerCoreSetFallback(t *testing.T) { - // Deployment root has no core.json (mimics composefs /usr). - if _, ok, _ := CoreSet(t.TempDir()); ok { - t.Fatal("expected no core set from an empty deployment root") - } - // The installer-embedded copy is readable. - embedded := filepath.Join(t.TempDir(), "core-flatpaks.json") - if err := os.WriteFile(embedded, []byte(`{"core":[{"id":"org.gnome.Loupe"}]}`), 0o644); err != nil { - t.Fatal(err) - } - prev := InstallerCoreJSONPath - InstallerCoreJSONPath = embedded - t.Cleanup(func() { InstallerCoreJSONPath = prev }) - - ids, ok, err := InstallerCoreSet() - if err != nil || !ok { - t.Fatalf("InstallerCoreSet ok=%v err=%v", ok, err) - } - if !slices.Equal(ids, []string{"org.gnome.Loupe"}) { - t.Fatalf("InstallerCoreSet ids = %v", ids) - } -} diff --git a/internal/flatpak/label.go b/internal/flatpak/label.go new file mode 100644 index 0000000..8096359 --- /dev/null +++ b/internal/flatpak/label.go @@ -0,0 +1,154 @@ +package flatpak + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strings" + "unicode" +) + +// CoreLabel is the OCI image label carrying the image's core Flatpak set +// (docs/specs/core-flatpaks-label.md, ADR-0018). +const CoreLabel = "org.frostyard.core-flatpaks" + +// coreLabelVersion is the only label format version this firn understands. +const coreLabelVersion = 1 + +// appIDRE is Flatpak's application ID grammar: at least three dot-separated +// elements of [A-Za-z0-9_-], none starting with a digit, '-' only in the +// last element. The 255-character limit is checked separately. +var appIDRE = regexp.MustCompile(`^(?:[A-Za-z_][A-Za-z0-9_]*\.){2,}[A-Za-z_-][A-Za-z0-9_-]*$`) + +// ErrMalformedCoreLabel wraps every rejection of a present label, so +// callers can tell a malformed label from other failures. +var ErrMalformedCoreLabel = errors.New("malformed " + CoreLabel + " label") + +// coreLabel mirrors the label's JSON. Pointers distinguish a missing or +// null field from a zero value. +type coreLabel struct { + Version *int `json:"version"` + Flatpaks *[]coreLabelItem `json:"flatpaks"` +} + +type coreLabelItem struct { + ID *string `json:"id"` + Name *string `json:"name"` +} + +// CoreApp is one entry of an image's core Flatpak set. +type CoreApp struct { + ID string + Name string +} + +// ParseCoreLabel returns the app IDs of an image's core Flatpak set from the +// value of its CoreLabel. present reports whether the label key exists. +// +// An absent label, or a valid label with an empty flatpaks array, is no +// core set: (nil, nil). Anything else that does not match the spec is an +// error wrapping ErrMalformedCoreLabel. Duplicate IDs are dropped, keeping +// the first occurrence. +func ParseCoreLabel(value string, present bool) ([]string, error) { + apps, err := ParseCoreLabelApps(value, present) + if err != nil { + return nil, err + } + var ids []string + for _, app := range apps { + ids = append(ids, app.ID) + } + return ids, nil +} + +// ParseCoreLabelApps is ParseCoreLabel keeping each app's display name, for +// the wizard's preview. +func ParseCoreLabelApps(value string, present bool) ([]CoreApp, error) { + if !present { + return nil, nil + } + malformed := func(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrMalformedCoreLabel, fmt.Sprintf(format, args...)) + } + if value == "" { + return nil, malformed("empty value") + } + // Decode accepts a bare null as an empty object; the label must be one. + if bytes.Equal(bytes.TrimSpace([]byte(value)), []byte("null")) { + return nil, malformed("not a JSON object") + } + dec := json.NewDecoder(strings.NewReader(value)) + dec.DisallowUnknownFields() + var label coreLabel + if err := dec.Decode(&label); err != nil { + return nil, malformed("%v", err) + } + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return nil, malformed("trailing data after the JSON object") + } + if label.Version == nil { + return nil, malformed("version is missing or null") + } + if *label.Version != coreLabelVersion { + return nil, malformed("unsupported version %d", *label.Version) + } + if label.Flatpaks == nil { + return nil, malformed("flatpaks is missing or null") + } + // encoding/json matches field names case-insensitively, so + // DisallowUnknownFields alone accepts "VERSION" or "Id". The spec's + // names are exact; check them at both object levels. + if err := exactFieldNames(value); err != nil { + return nil, malformed("%v", err) + } + var apps []CoreApp + seen := make(map[string]bool, len(*label.Flatpaks)) + for i, item := range *label.Flatpaks { + if item.ID == nil || *item.ID == "" || len(*item.ID) > 255 || !appIDRE.MatchString(*item.ID) { + return nil, malformed("flatpaks[%d]: invalid Flatpak application ID", i) + } + if item.Name == nil || strings.TrimSpace(*item.Name) == "" { + return nil, malformed("flatpaks[%d]: name is missing or empty", i) + } + // Names reach the installer console before the image is verified; + // control characters and escape sequences must not. + if strings.IndexFunc(*item.Name, func(r rune) bool { return !unicode.IsPrint(r) }) >= 0 { + return nil, malformed("flatpaks[%d]: name contains a non-printable character", i) + } + if !seen[*item.ID] { + seen[*item.ID] = true + apps = append(apps, CoreApp{ID: *item.ID, Name: *item.Name}) + } + } + return apps, nil +} + +// exactFieldNames rejects any key in the label object, or in a flatpaks +// entry, that is not spelled exactly as the spec spells it. It runs after +// the typed decode, so the shapes it walks are already known to be valid. +func exactFieldNames(value string) error { + var top map[string]json.RawMessage + if err := json.Unmarshal([]byte(value), &top); err != nil { + return err + } + for key := range top { + if key != "version" && key != "flatpaks" { + return fmt.Errorf("unknown field %q", key) + } + } + var items []map[string]json.RawMessage + if err := json.Unmarshal(top["flatpaks"], &items); err != nil { + return err + } + for i, item := range items { + for key := range item { + if key != "id" && key != "name" { + return fmt.Errorf("flatpaks[%d]: unknown field %q", i, key) + } + } + } + return nil +} diff --git a/internal/flatpak/label_test.go b/internal/flatpak/label_test.go new file mode 100644 index 0000000..fbb5cad --- /dev/null +++ b/internal/flatpak/label_test.go @@ -0,0 +1,112 @@ +package flatpak + +import ( + "errors" + "slices" + "strings" + "testing" +) + +// TestParseCoreLabel pins every row of the ADR-0018 / core-flatpaks-label +// spec table. The rejection cases mirror snosi's producer test +// (test/core-flatpaks-test.sh) so producer and consumer agree on them. +func TestParseCoreLabel(t *testing.T) { + const app = `{"id":"org.example.App","name":"App"}` + for _, tc := range []struct { + name string + value string + present bool + want []string // nil with wantErr false means no core set + wantErr bool + }{ + {name: "key absent", present: false}, + {name: "valid set", present: true, + value: `{"version":1,"flatpaks":[{"id":"org.mozilla.firefox","name":"Firefox"},` + app + `]}`, + want: []string{"org.mozilla.firefox", "org.example.App"}}, + {name: "re-encoded with whitespace", present: true, + value: "{\n \"version\": 1,\n \"flatpaks\": [" + app + "]\n}\n", + want: []string{"org.example.App"}}, + {name: "empty flatpaks array is no core set", present: true, + value: `{"version":1,"flatpaks":[]}`}, + {name: "duplicates keep the first occurrence", present: true, + value: `{"version":1,"flatpaks":[{"id":"org.b.B","name":"B"},` + app + `,{"id":"org.b.B","name":"B again"}]}`, + want: []string{"org.b.B", "org.example.App"}}, + {name: "name with spaces and non-ASCII letters", present: true, + value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"Visionneuse d’images"}]}`, + want: []string{"org.example.App"}}, + {name: "dash in the last element", present: true, + value: `{"version":1,"flatpaks":[{"id":"org.example.my-app","name":"App"}]}`, + want: []string{"org.example.my-app"}}, + + {name: "empty string", present: true, value: "", wantErr: true}, + {name: "null", present: true, value: "null", wantErr: true}, + {name: "not JSON", present: true, value: "not json", wantErr: true}, + {name: "array, not object", present: true, value: "[" + app + "]", wantErr: true}, + {name: "trailing data", present: true, value: `{"version":1,"flatpaks":[` + app + `]} {}`, wantErr: true}, + {name: "version missing", present: true, value: `{"flatpaks":[` + app + `]}`, wantErr: true}, + {name: "version null", present: true, value: `{"version":null,"flatpaks":[` + app + `]}`, wantErr: true}, + {name: "version 2", present: true, value: `{"version":2,"flatpaks":[` + app + `]}`, wantErr: true}, + {name: "version 1.0", present: true, value: `{"version":1.0,"flatpaks":[` + app + `]}`, wantErr: true}, + {name: "version true", present: true, value: `{"version":true,"flatpaks":[` + app + `]}`, wantErr: true}, + {name: "version string", present: true, value: `{"version":"1","flatpaks":[` + app + `]}`, wantErr: true}, + {name: "flatpaks missing", present: true, value: `{"version":1}`, wantErr: true}, + {name: "flatpaks null", present: true, value: `{"version":1,"flatpaks":null}`, wantErr: true}, + {name: "unknown top-level field", present: true, value: `{"version":1,"flatpaks":[` + app + `],"extra":1}`, wantErr: true}, + {name: "uppercase top-level fields", present: true, value: `{"VERSION":1,"FLATPAKS":[` + app + `]}`, wantErr: true}, + {name: "capitalized version only", present: true, value: `{"Version":1,"flatpaks":[` + app + `]}`, wantErr: true}, + {name: "uppercase entry fields", present: true, value: `{"version":1,"flatpaks":[{"ID":"org.example.App","NAME":"App"}]}`, wantErr: true}, + {name: "case-variant duplicate key", present: true, value: `{"version":1,"Version":2,"flatpaks":[` + app + `]}`, wantErr: true}, + {name: "unknown entry field", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"App","x":1}]}`, wantErr: true}, + {name: "null entry", present: true, value: `{"version":1,"flatpaks":[null]}`, wantErr: true}, + {name: "id missing", present: true, value: `{"version":1,"flatpaks":[{"name":"App"}]}`, wantErr: true}, + {name: "id empty", present: true, value: `{"version":1,"flatpaks":[{"id":"","name":"App"}]}`, wantErr: true}, + {name: "id with two elements", present: true, value: `{"version":1,"flatpaks":[{"id":"org.App","name":"App"}]}`, wantErr: true}, + {name: "element starting with a digit", present: true, value: `{"version":1,"flatpaks":[{"id":"org.1example.App","name":"App"}]}`, wantErr: true}, + {name: "dash before the last element", present: true, value: `{"version":1,"flatpaks":[{"id":"org.ex-ample.App","name":"App"}]}`, wantErr: true}, + {name: "id with a space", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.My App","name":"App"}]}`, wantErr: true}, + {name: "id with a leading option dash", present: true, value: `{"version":1,"flatpaks":[{"id":"--system","name":"App"}]}`, wantErr: true}, + {name: "id over 255 characters", present: true, + value: `{"version":1,"flatpaks":[{"id":"org.example.` + strings.Repeat("a", 250) + `","name":"App"}]}`, wantErr: true}, + {name: "name with an escape sequence", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"B\u001b[2J\u001b[31mEVIL"}]}`, wantErr: true}, + {name: "name with a newline", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"B\nC"}]}`, wantErr: true}, + {name: "name with a tab", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"B\tC"}]}`, wantErr: true}, + {name: "name with a zero-width joiner", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"B\u200dC"}]}`, wantErr: true}, + {name: "name missing", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App"}]}`, wantErr: true}, + {name: "name empty", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":" "}]}`, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseCoreLabel(tc.value, tc.present) + if tc.wantErr { + if !errors.Is(err, ErrMalformedCoreLabel) { + t.Fatalf("ParseCoreLabel(%q) error = %v, want ErrMalformedCoreLabel", tc.value, err) + } + if got != nil { + t.Fatalf("ParseCoreLabel(%q) returned %v alongside an error", tc.value, got) + } + return + } + if err != nil { + t.Fatalf("ParseCoreLabel(%q) error = %v", tc.value, err) + } + if !slices.Equal(got, tc.want) { + t.Fatalf("ParseCoreLabel(%q) = %v, want %v", tc.value, got, tc.want) + } + }) + } +} + +// The error names the actual problem, so the wizard note and the preflight +// error tell the user what is wrong with the label. +func TestParseCoreLabelMessages(t *testing.T) { + for value, want := range map[string]string{ + `{"version":1}`: "flatpaks is missing or null", + `{"flatpaks":[]}`: "version is missing or null", + `{"version":1,"FLATPAKS":[]}`: `unknown field "FLATPAKS"`, + `{"version":1,"flatpaks":[],"Version":1}`: `unknown field "Version"`, + } { + _, err := ParseCoreLabel(value, true) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("ParseCoreLabel(%s) error = %v, want it to mention %q", value, err, want) + } + } +} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 242aee3..7ccf218 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -59,6 +59,10 @@ type Env struct { // cosign verification is requested, it is the verified immutable digest; // the original recipe ref remains the day-two tracking reference. BootcSourceRef string + // CoreFlatpaks is the core Flatpak set preflight-image read from the + // selected image's label when the recipe sets core_flatpaks (ADR-0018); + // empty when the image publishes none. + CoreFlatpaks []string // SecureImageRoot is an extracted tree of the secure image's // usr/lib/{snosi,shim} subtrees (secureboot.ExtractSecureImageRoot), // populated by bootc-install for a secure bootc install and consumed by diff --git a/internal/progress/progress.go b/internal/progress/progress.go index 26fa279..9da9046 100644 --- a/internal/progress/progress.go +++ b/internal/progress/progress.go @@ -113,18 +113,20 @@ func (f EmitterFunc) Emit(event Event) error { return f(event) } // Warning and error codes stable enough to be part of the contract are // collected here as they are introduced (spec rule 3). const ( - CodeStepFailed = "step_failed" - CodeCleanupFailed = "cleanup_failed" - CodeImageVerifyFailed = "image_verification_failed" - CodeImageVerifyRetried = "image_verification_retried" - CodeNoTPM = "no_tpm" - CodeFlatpakUnreachable = "flatpak_unreachable" - CodeGroupMissing = "group_missing" - CodeNoCoreSet = "no_core_set" - CodeStoreUnmountFailed = "store_umount_failed" - CodeStoreCleanupFailed = "store_cleanup_failed" - CodeStreamTruncated = "stream_truncated" - CodeRecipeV1Deprecated = "recipe_v1_deprecated" + CodeStepFailed = "step_failed" + CodeCleanupFailed = "cleanup_failed" + CodeImageVerifyFailed = "image_verification_failed" + CodeImageVerifyRetried = "image_verification_retried" + CodeNoTPM = "no_tpm" + CodeFlatpakUnreachable = "flatpak_unreachable" + CodeGroupMissing = "group_missing" + CodeNoCoreSet = "no_core_set" + CodeCoreLabelInvalid = "core_flatpaks_label_invalid" + CodeCoreLabelUnreadable = "core_flatpaks_label_unreadable" + CodeStoreUnmountFailed = "store_umount_failed" + CodeStoreCleanupFailed = "store_cleanup_failed" + CodeStreamTruncated = "stream_truncated" + CodeRecipeV1Deprecated = "recipe_v1_deprecated" ) func isTerminal(e Event) bool { diff --git a/internal/steps/bootc.go b/internal/steps/bootc.go index 0997c28..f9149cf 100644 --- a/internal/steps/bootc.go +++ b/internal/steps/bootc.go @@ -561,39 +561,15 @@ func findBootcUKI(root string) (uki string, allEFI []string) { } func runFlatpaks(ctx context.Context, env *pipeline.Env) error { - apps := env.Recipe.System.Flatpaks - if env.Recipe.System.CoreFlatpaks { - // The core set ships in the image's /usr. On an ostree - // deployment that tree is materialized on disk; on - // composefs-native targets /usr lives in composefs objects and - // is not readable as plain files at install time — report, - // don't guess. - etcDir, err := sysconfig.EtcDir(ctx, env.Runner, targetDir(env)) - if err != nil { - return err - } - core, ok, err := flatpak.CoreSet(filepath.Dir(etcDir)) - if err != nil { - return err - } - if !ok { - // Composefs-native deployments (every snosi desktop image) - // keep /usr in composefs objects unreadable at install time, - // so the deployment publishes no readable core set. Fall back - // to the copy the installer medium embeds. - core, ok, err = flatpak.InstallerCoreSet() - if err != nil { - return err - } - } - if !ok { - msg := "core_flatpaks: no readable core set in the deployment or on the installer medium" - if err := env.Emit(progress.Warning{Code: progress.CodeNoCoreSet, Message: msg}); err != nil { - return err - } - env.AddSummary(progress.CodeNoCoreSet, msg) + // Explicit apps first, then the core set preflight-image read from the + // image's label (ADR-0018); each ID once, at its first occurrence. + var apps []string + seen := make(map[string]bool) + for _, app := range append(append([]string{}, env.Recipe.System.Flatpaks...), env.CoreFlatpaks...) { + if !seen[app] { + seen[app] = true + apps = append(apps, app) } - apps = append(apps, core...) } if len(apps) == 0 { return nil diff --git a/internal/steps/core_flatpaks_test.go b/internal/steps/core_flatpaks_test.go new file mode 100644 index 0000000..0a3b9c1 --- /dev/null +++ b/internal/steps/core_flatpaks_test.go @@ -0,0 +1,246 @@ +package steps + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/frostyard/firn/internal/flatpak" + "github.com/frostyard/firn/internal/pipeline" + "github.com/frostyard/firn/internal/progress" + "github.com/frostyard/firn/internal/recipe" + "github.com/frostyard/firn/internal/runner" +) + +// coreLabelFake answers preflight for a clean UEFI machine whose image +// carries labels; every command is recorded in commands. +func coreLabelFake(t *testing.T, labels map[string]string, commands *[]string) *runner.Runner { + return coreLabelFakeWith(t, labels, commands, nil) +} + +// coreLabelFakeWith is coreLabelFake with an optional override for skopeo. +func coreLabelFakeWith(t *testing.T, labels map[string]string, commands *[]string, + skopeo func(args []string) ([]byte, error)) *runner.Runner { + t.Helper() + const lsblkJSON = `{"blockdevices": [ + {"path": "/dev/vda", "type": "disk", "size": 64000000000, "fstype": null, "label": null, "mountpoints": [null]}]}` + inspect := `{"Digest":"` + verifiedBootcDigest + `","Labels":{"containers.bootc":"1"` + for k, v := range labels { + inspect += `,` + jsonString(k) + `:` + jsonString(v) + } + inspect += `}}` + return runner.NewFake( + func(_ context.Context, name string, args ...string) ([]byte, error) { + *commands = append(*commands, strings.Join(append([]string{name}, args...), " ")) + switch name { + case "lsblk": + return []byte(lsblkJSON), nil + case "findmnt": + return []byte("overlay\n"), nil + case "skopeo": + if skopeo != nil { + return skopeo(args) + } + return []byte(inspect), nil + } + return []byte(""), nil + }, + func(name string) (string, error) { return "/usr/bin/" + name, nil }, + ) +} + +func jsonString(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` +} + +func coreRecipe(t *testing.T, coreFlatpaks bool) *recipe.Loaded { + t.Helper() + src := strings.Replace(bootcBase, "%s", "none", 1) + if coreFlatpaks { + src += "core_flatpaks = true\n" + } + return load(t, src) +} + +func runCore(t *testing.T, l *recipe.Loaded, r *runner.Runner, dryRun bool) (*pipeline.Env, []progress.Event, error) { + t.Helper() + var events []progress.Event + env := &pipeline.Env{ + Recipe: &l.Recipe, Runner: r, UEFI: true, Version: "test", + TargetDir: filepath.Join(t.TempDir(), "target"), + ScratchDir: filepath.Join(t.TempDir(), "scratch"), + Emitter: progress.EmitterFunc(func(e progress.Event) error { + events = append(events, e) + return nil + }), + } + err := Assemble(l).Run(context.Background(), env, dryRun) + return env, events, err +} + +// A malformed label with core_flatpaks requested stops the install in +// preflight-image, before any disk write, in both dry-run and real runs +// (ADR-0018). +func TestCoreFlatpaksMalformedLabelFailsPreflight(t *testing.T) { + for _, dryRun := range []bool{true, false} { + var commands []string + r := coreLabelFake(t, map[string]string{flatpak.CoreLabel: `{"version":2,"flatpaks":[]}`}, &commands) + _, events, err := runCore(t, coreRecipe(t, true), r, dryRun) + if !errors.Is(err, flatpak.ErrMalformedCoreLabel) { + t.Fatalf("dryRun=%v: error = %v, want a malformed-label error", dryRun, err) + } + terminal, ok := events[len(events)-1].(progress.Error) + if !ok || terminal.Step != "preflight-image" || terminal.Code != progress.CodeCoreLabelInvalid { + t.Fatalf("dryRun=%v: terminal event = %#v", dryRun, events[len(events)-1]) + } + for _, command := range commands { + for _, destructive := range []string{"sfdisk", "wipefs", "mkfs", "cryptsetup", "bootc", "podman"} { + if strings.HasPrefix(command, destructive+" ") { + t.Fatalf("dryRun=%v: %s ran after a malformed label", dryRun, command) + } + } + } + } +} + +// Without core_flatpaks the label is never parsed, so a malformed one +// cannot block the install. +func TestCoreFlatpaksLabelIgnoredWhenNotRequested(t *testing.T) { + var commands []string + r := coreLabelFake(t, map[string]string{flatpak.CoreLabel: "not json"}, &commands) + env, _, err := runCore(t, coreRecipe(t, false), r, true) + if err != nil { + t.Fatalf("dry run = %v, want success with core_flatpaks off", err) + } + if env.CoreFlatpaks != nil { + t.Fatalf("CoreFlatpaks = %v, want none", env.CoreFlatpaks) + } +} + +// An image that publishes no core set is reported in preflight and the +// install continues. +func TestCoreFlatpaksAbsentLabelWarns(t *testing.T) { + for name, labels := range map[string]map[string]string{ + "no label": nil, + "empty array": {flatpak.CoreLabel: `{"version":1,"flatpaks":[]}`}, + } { + var commands []string + env, events, err := runCore(t, coreRecipe(t, true), coreLabelFake(t, labels, &commands), true) + if err != nil { + t.Fatalf("%s: dry run = %v, want success", name, err) + } + var warned bool + for _, e := range events { + if w, ok := e.(progress.Warning); ok && w.Code == progress.CodeNoCoreSet { + warned = true + } + } + if !warned { + t.Fatalf("%s: no %s warning in %v", name, progress.CodeNoCoreSet, events) + } + if len(env.Summary) != 1 || env.Summary[0].Code != progress.CodeNoCoreSet { + t.Fatalf("%s: summary = %v", name, env.Summary) + } + if env.CoreFlatpaks != nil { + t.Fatalf("%s: CoreFlatpaks = %v, want none", name, env.CoreFlatpaks) + } + } +} + +func TestCoreFlatpaksValidLabelIsKeptForTheFlatpakStep(t *testing.T) { + var commands []string + label := `{"version":1,"flatpaks":[{"id":"org.kde.okular","name":"Okular"},{"id":"org.kde.kcalc","name":"KCalc"}]}` + env, _, err := runCore(t, coreRecipe(t, true), coreLabelFake(t, map[string]string{flatpak.CoreLabel: label}, &commands), true) + if err != nil { + t.Fatal(err) + } + if want := []string{"org.kde.okular", "org.kde.kcalc"}; !slices.Equal(env.CoreFlatpaks, want) { + t.Fatalf("CoreFlatpaks = %v, want %v", env.CoreFlatpaks, want) + } + if len(env.Summary) != 0 { + t.Fatalf("summary = %v, want none", env.Summary) + } +} + +// runFlatpaks installs explicit apps first, then the core set, each ID +// once at its first occurrence. +func TestRunFlatpaksMergesExplicitThenCore(t *testing.T) { + target := t.TempDir() + var installed []string + r := runner.NewFake( + func(_ context.Context, name string, args ...string) ([]byte, error) { + switch name { + case "ls": + return nil, errors.New("no such file") // plain var/lib/flatpak target + case "du": + return []byte("0\t/x\n"), nil + case "env": + if i := slices.Index(args, "install"); i >= 0 { + installed = append(installed, args[len(args)-1]) + } + } + return []byte(""), nil + }, + func(name string) (string, error) { return "/usr/bin/" + name, nil }, + ) + l := load(t, strings.Replace(bootcBase, "%s", "none", 1)+ + `flatpaks = ["org.mozilla.firefox", "org.kde.okular", "org.mozilla.firefox"]`+"\n") + env := &pipeline.Env{ + Recipe: &l.Recipe, Runner: r, TargetDir: target, + CoreFlatpaks: []string{"org.kde.okular", "org.kde.kcalc"}, + Emitter: progress.EmitterFunc(func(progress.Event) error { return nil }), + } + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + if err := runFlatpaks(context.Background(), env); err != nil { + t.Fatal(err) + } + if want := []string{"org.mozilla.firefox", "org.kde.okular", "org.kde.kcalc"}; !slices.Equal(installed, want) { + t.Fatalf("installed = %v, want %v", installed, want) + } +} + +// A signed digest can verify while its registry inspection failed. Its +// labels are then unknown, which must not pass as "no core set". +func TestCoreFlatpaksUninspectableImageFailsPreflight(t *testing.T) { + cosignKey := filepath.Join(t.TempDir(), "cosign.pub") + if err := os.WriteFile(cosignKey, []byte("public key"), 0o644); err != nil { + t.Fatal(err) + } + src := strings.Replace(strings.Replace(bootcBase, "%s", "none", 1), + `ref = "ghcr.io/frostyard/snow:latest"`, + `ref = "ghcr.io/frostyard/snow@`+verifiedBootcDigest+`"`+"\ncosign_pub_key = \""+cosignKey+`"`, 1) + offline := func([]string) ([]byte, error) { return nil, errors.New("registry 503") } + for _, core := range []bool{true, false} { + var commands []string + body := src + if core { + body += "core_flatpaks = true\n" + } + _, events, err := runCore(t, load(t, body), coreLabelFakeWith(t, nil, &commands, offline), true) + if !core { + if err != nil { + t.Fatalf("core_flatpaks off: dry run = %v, want success (labels not needed)", err) + } + continue + } + if err == nil || !strings.Contains(err.Error(), "could not inspect image") { + t.Fatalf("core_flatpaks on: error = %v, want an inspection failure", err) + } + terminal, ok := events[len(events)-1].(progress.Error) + if !ok || terminal.Step != "preflight-image" || terminal.Code != progress.CodeCoreLabelUnreadable { + t.Fatalf("terminal event = %#v, want a preflight-image %s error", + events[len(events)-1], progress.CodeCoreLabelUnreadable) + } + for _, e := range events { + if w, ok := e.(progress.Warning); ok && w.Code == progress.CodeNoCoreSet { + t.Fatal("unknown labels were reported as no_core_set") + } + } + } +} diff --git a/internal/steps/preflight.go b/internal/steps/preflight.go index a50bd8c..c086895 100644 --- a/internal/steps/preflight.go +++ b/internal/steps/preflight.go @@ -7,6 +7,7 @@ import ( "github.com/frostyard/firn/internal/bootcimg" "github.com/frostyard/firn/internal/disk" + "github.com/frostyard/firn/internal/flatpak" "github.com/frostyard/firn/internal/pipeline" "github.com/frostyard/firn/internal/platform" "github.com/frostyard/firn/internal/progress" @@ -54,7 +55,7 @@ func preflightSteps(p *pipeline.Pipeline, r *recipe.Recipe) []pipeline.Step { steps = append(steps, pipeline.Step{ Name: "preflight-image", Weight: 1, Preflight: true, Run: func(ctx context.Context, env *pipeline.Env) error { - pinned, err := bootcimg.CheckAndPinImage(ctx, env.Runner, env.Recipe.Image.Ref, env.Recipe.Image.CosignPubKey, + source, err := bootcimg.CheckAndPinImage(ctx, env.Runner, env.Recipe.Image.Ref, env.Recipe.Image.CosignPubKey, func(msg string) { _ = env.Emit(progress.Warning{Code: progress.CodeImageVerifyRetried, Message: msg}) }) @@ -64,12 +65,15 @@ func preflightSteps(p *pipeline.Pipeline, r *recipe.Recipe) []pipeline.Step { } return err } - env.BootcSourceRef = pinned + env.BootcSourceRef = source.Ref if env.Recipe.Image.CosignPubKey != "" { if err := env.Emit(progress.Info{Message: "bootc image signature verified at immutable digest"}); err != nil { return err } } + if env.Recipe.System.CoreFlatpaks { + return readCoreFlatpaks(env, source) + } return nil }, }) @@ -105,3 +109,32 @@ func diskPaths(devices []disk.Device) string { } return strings.Join(paths, ", ") } + +// readCoreFlatpaks parses the selected image's core Flatpak label before any +// disk write (ADR-0018): a malformed label fails the install here, and an +// image that publishes no set is reported, not fatal. +func readCoreFlatpaks(env *pipeline.Env, source bootcimg.Source) error { + if !source.Inspected { + // Unknown labels are not an absent label: installing without the + // requested set, or skipping its validation, would be silent. + return pipeline.WithErrorCode(progress.CodeCoreLabelUnreadable, + fmt.Errorf("core_flatpaks: could not inspect image %s to read its %s label; check registry access and retry", + env.Recipe.Image.Ref, flatpak.CoreLabel)) + } + value, present := source.Labels[flatpak.CoreLabel] + ids, err := flatpak.ParseCoreLabel(value, present) + if err != nil { + return pipeline.WithErrorCode(progress.CodeCoreLabelInvalid, + fmt.Errorf("core_flatpaks: image %s: %w", env.Recipe.Image.Ref, err)) + } + if len(ids) == 0 { + msg := fmt.Sprintf("core_flatpaks: image %s publishes no core Flatpak set", env.Recipe.Image.Ref) + if err := env.Emit(progress.Warning{Code: progress.CodeNoCoreSet, Message: msg}); err != nil { + return err + } + env.AddSummary(progress.CodeNoCoreSet, msg) + return nil + } + env.CoreFlatpaks = ids + return env.Emit(progress.Info{Message: fmt.Sprintf("core_flatpaks: image publishes %d core Flatpaks", len(ids))}) +} diff --git a/internal/tui/core_preview.go b/internal/tui/core_preview.go new file mode 100644 index 0000000..0c61961 --- /dev/null +++ b/internal/tui/core_preview.go @@ -0,0 +1,210 @@ +package tui + +import ( + "context" + "errors" + "os" + "strings" + "time" + "unicode" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/frostyard/firn/internal/bootcimg" + "github.com/frostyard/firn/internal/flatpak" +) + +// corePreviewTimeout bounds the flatpaks page's image inspection, so an +// unreachable registry delays the page instead of hanging it. +const corePreviewTimeout = 30 * time.Second + +// corePreviewState is what the flatpaks page knows about the chosen image's +// core Flatpak set (ADR-0018). The zero value, unavailable, keeps the +// toggle: install-time preflight reads the label again either way. +type corePreviewState int + +const ( + corePreviewUnavailable corePreviewState = iota // inspect failed or not attempted + corePreviewNone // image publishes no core set + corePreviewInvalid // label present but malformed + corePreviewAvailable // valid, non-empty set +) + +// corePreview describes the core set of the image whose ref it records. It +// is display-only: the recipe carries just core_flatpaks, and preflight +// reads the label from the image it selects at install time. +type corePreview struct { + ref string + state corePreviewState + apps []flatpak.CoreApp + detail string +} + +// refreshCorePreview inspects the chosen image when the preview describes a +// different one (first visit, a new image, or Start over), using the same +// local-first selection preflight uses, without signature verification. +// quit reports that the user aborted while it ran. +func (w *wizard) refreshCorePreview(ctx context.Context) (quit bool, err error) { + ref := w.c.entry.Ref + if w.preview.ref == ref && ref != "" { + return false, nil + } + inspect := func(ctx context.Context) corePreview { return inspectCorePreview(ctx, w, ref) } + if w.showProgress == nil { + w.preview = inspect(ctx) + return false, nil + } + preview, quit, err := w.showProgress(ctx, "Reading "+w.c.entry.Name+"'s core app list…", inspect) + if quit || err != nil { + return quit, err + } + w.preview = preview + return false, nil +} + +// progressFunc runs inspect while showing title, returning its result. +// quit reports a user abort. +type progressFunc func(ctx context.Context, title string, inspect func(context.Context) corePreview) (preview corePreview, quit bool, err error) + +// showInspecting draws a spinner while inspect runs, so a slow registry +// reads as progress rather than a hung installer, and keystrokes typed +// meanwhile are consumed here instead of reaching the flatpaks form. Like +// page, it renders to stdout (the kiosk routes stderr to the journal). +func showInspecting(ctx context.Context, title string, inspect func(context.Context) corePreview) (corePreview, bool, error) { + ictx, cancel := context.WithCancel(ctx) + defer cancel() + m := &inspectingModel{ + spin: spinner.New(spinner.WithSpinner(spinner.Line)), + title: title, + inspect: func() corePreview { return inspect(ictx) }, + } + _, err := tea.NewProgram(m, + tea.WithContext(ctx), + tea.WithInput(os.Stdin), + tea.WithOutput(os.Stdout), + ).Run() + if ctx.Err() != nil { + return corePreview{}, false, ctx.Err() + } + if errors.Is(err, tea.ErrInterrupted) { + return corePreview{}, true, nil + } + if err != nil { + return corePreview{}, false, err + } + return m.result, false, nil +} + +// inspectingModel is the spinner shown while the preview inspection runs. +type inspectingModel struct { + spin spinner.Model + title string + inspect func() corePreview + done bool + result corePreview +} + +type previewDoneMsg struct{ preview corePreview } + +func (m *inspectingModel) Init() tea.Cmd { + return tea.Batch(m.spin.Tick, func() tea.Msg { return previewDoneMsg{m.inspect()} }) +} + +func (m *inspectingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case previewDoneMsg: + m.result, m.done = msg.preview, true + return m, tea.Quit + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC || msg.Type == tea.KeyEsc { + return m, tea.Interrupt + } + return m, nil // swallow keys typed while waiting + } + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd +} + +func (m *inspectingModel) View() string { + if m.done { + return "" + } + return m.spin.View() + " " + m.title + "\n" +} + +func inspectCorePreview(ctx context.Context, w *wizard, ref string) corePreview { + p := corePreview{ref: ref} + if w.opts.Runner == nil || ref == "" { + p.detail = "no image inspector" + return p + } + ictx, cancel := context.WithTimeout(ctx, corePreviewTimeout) + defer cancel() + source, err := bootcimg.CheckAndPinImage(ictx, w.opts.Runner, ref, "", nil) + if err == nil && !source.Inspected { + err = errors.New("image inspection returned no readable metadata") + } + if err != nil { + // Keep the next visit retrying rather than caching a transient + // failure such as a slow or offline registry. + p.ref = "" + p.detail = firstLine(err.Error()) + return p + } + value, present := source.Labels[flatpak.CoreLabel] + apps, err := flatpak.ParseCoreLabelApps(value, present) + switch { + case errors.Is(err, flatpak.ErrMalformedCoreLabel): + p.state = corePreviewInvalid + p.detail = firstLine(err.Error()) + case err != nil: + p.ref = "" + p.detail = firstLine(err.Error()) + case len(apps) == 0: + p.state = corePreviewNone + default: + p.state = corePreviewAvailable + p.apps = apps + } + return p +} + +// firstLine returns s's first line with non-printable runes replaced, so +// registry or skopeo text cannot put escape sequences on the console. +func firstLine(s string) string { + line, _, _ := strings.Cut(s, "\n") + return strings.Map(func(r rune) rune { + if unicode.IsPrint(r) { + return r + } + return '?' + }, line) +} + +// wrapNames joins app names into lines no wider than width display cells. +func wrapNames(apps []flatpak.CoreApp, width int) string { + var lines []string + var line string + for i, app := range apps { + item := app.Name + if i < len(apps)-1 { + item += "," + } + switch { + case line == "": + line = item + case lipgloss.Width(line)+1+lipgloss.Width(item) > width: + lines = append(lines, line) + line = item + default: + line += " " + item + } + } + if line != "" { + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/core_preview_test.go b/internal/tui/core_preview_test.go new file mode 100644 index 0000000..dfee548 --- /dev/null +++ b/internal/tui/core_preview_test.go @@ -0,0 +1,283 @@ +package tui + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + + "github.com/frostyard/firn/internal/flatpak" + "github.com/frostyard/firn/internal/runner" +) + +const previewDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + +// previewRunner serves skopeo inspections whose core label depends on the +// image ref; refs missing from labels fail as unreachable. inspected counts +// skopeo invocations. +func previewRunner(labels map[string]string, inspected *int) *runner.Runner { + return runner.NewFake( + func(_ context.Context, name string, args ...string) ([]byte, error) { + if name != "skopeo" { + return nil, errors.New("unexpected " + name) + } + *inspected++ + ref := args[1][strings.Index(args[1], ":")+1:] + ref = strings.TrimPrefix(ref, "//") + value, ok := labels[ref] + if !ok { + return nil, errors.New("network unreachable") + } + body := `{"Digest":"` + previewDigest + `","Labels":{"containers.bootc":"1"` + if value != "ABSENT" { + body += `,"` + flatpak.CoreLabel + `":"` + strings.ReplaceAll(value, `"`, `\"`) + `"` + } + return []byte(body + `}}`), nil + }, + func(name string) (string, error) { return "/usr/bin/" + name, nil }, + ) +} + +const ( + snowRef = "ghcr.io/frostyard/snow:latest" + sundogRef = "ghcr.io/frostyard/sundog:latest" + floeRef = "ghcr.io/frostyard/floe:latest" + brokenRef = "ghcr.io/frostyard/broken:latest" + emptyRef = "ghcr.io/frostyard/empty:latest" +) + +var previewLabels = map[string]string{ + snowRef: `{"version":1,"flatpaks":[{"id":"org.gnome.Loupe","name":"Image Viewer"},{"id":"org.mozilla.firefox","name":"Firefox"}]}`, + sundogRef: `{"version":1,"flatpaks":[{"id":"org.kde.okular","name":"Okular"}]}`, + floeRef: "ABSENT", + brokenRef: `{"version":2,"flatpaks":[]}`, + emptyRef: `{"version":1,"flatpaks":[]}`, +} + +func previewWizard(inspected *int) *wizard { + return &wizard{opts: WizardOpts{Runner: previewRunner(previewLabels, inspected)}} +} + +func TestCorePreviewStates(t *testing.T) { + for _, tc := range []struct { + ref string + state corePreviewState + names []string + cached bool + }{ + {ref: snowRef, state: corePreviewAvailable, names: []string{"Image Viewer", "Firefox"}, cached: true}, + {ref: floeRef, state: corePreviewNone, cached: true}, + {ref: emptyRef, state: corePreviewNone, cached: true}, + {ref: brokenRef, state: corePreviewInvalid, cached: true}, + {ref: "ghcr.io/frostyard/offline:latest", state: corePreviewUnavailable, cached: false}, + } { + var inspected int + w := previewWizard(&inspected) + w.c.entry = CatalogEntry{Name: "x", Ref: tc.ref} + mustRefresh(t, w) + if w.preview.state != tc.state { + t.Fatalf("%s: state = %v, want %v (detail %q)", tc.ref, w.preview.state, tc.state, w.preview.detail) + } + var names []string + for _, app := range w.preview.apps { + names = append(names, app.Name) + } + if strings.Join(names, ",") != strings.Join(tc.names, ",") { + t.Fatalf("%s: names = %v, want %v", tc.ref, names, tc.names) + } + before := inspected + mustRefresh(t, w) + if reused := inspected == before; reused != tc.cached { + t.Fatalf("%s: second visit reused the preview = %v, want %v", tc.ref, reused, tc.cached) + } + } +} + +// Choosing another image (going back, or Start over) re-inspects, and a +// hidden toggle never leaves core_flatpaks set for the new image. +func TestCorePreviewFollowsImageChangesAndClearsHiddenToggle(t *testing.T) { + var inspected int + w := previewWizard(&inspected) + + w.c.entry = CatalogEntry{Name: "snow", Ref: snowRef} + mustRefresh(t, w) + w.flatpaksForm() + w.c.coreFlatpaks = true // the user accepts Snow's set + + w.c.entry = CatalogEntry{Name: "floe", Ref: floeRef} + mustRefresh(t, w) + w.flatpaksForm() + if w.preview.state != corePreviewNone || w.c.coreFlatpaks { + t.Fatalf("after switching to floe: state = %v, coreFlatpaks = %v, want none and false", + w.preview.state, w.c.coreFlatpaks) + } + + w.c.entry = CatalogEntry{Name: "broken", Ref: brokenRef} + w.c.coreFlatpaks = true + mustRefresh(t, w) + w.flatpaksForm() + if w.preview.state != corePreviewInvalid || w.c.coreFlatpaks { + t.Fatalf("after switching to a malformed label: state = %v, coreFlatpaks = %v", + w.preview.state, w.c.coreFlatpaks) + } + + w.c.entry = CatalogEntry{Name: "sundog", Ref: sundogRef} + mustRefresh(t, w) + w.c.coreFlatpaks = true + w.flatpaksForm() + if w.preview.state != corePreviewAvailable || !w.c.coreFlatpaks { + t.Fatalf("an offered toggle must keep its answer: state = %v, coreFlatpaks = %v", + w.preview.state, w.c.coreFlatpaks) + } +} + +func TestFlatpaksFormRendersEachPreviewState(t *testing.T) { + for _, tc := range []struct { + ref string + want []string + notWant []string + }{ + {ref: snowRef, want: []string{"core app set?", "Image Viewer, Firefox"}}, + {ref: floeRef, want: []string{"No core app set", "publishes no core Flatpak apps"}, notWant: []string{"core app set?"}}, + {ref: brokenRef, want: []string{"Core app set unavailable", "malformed"}, notWant: []string{"core app set?"}}, + {ref: "ghcr.io/frostyard/offline:latest", want: []string{"core app set?", "read", "install time"}}, + } { + var inspected int + w := previewWizard(&inspected) + w.c.entry = CatalogEntry{Name: "x", Ref: tc.ref} + mustRefresh(t, w) + m := newPageModel(w, w.flatpaksForm()) + pumpKeys(t, m, tea.WindowSizeMsg{Width: 100, Height: 40}) + view := m.View() + for _, s := range tc.want { + if !strings.Contains(view, s) { + t.Errorf("%s: view lacks %q:\n%s", tc.ref, s, view) + } + } + for _, s := range tc.notWant { + if strings.Contains(view, s) { + t.Errorf("%s: view shows %q:\n%s", tc.ref, s, view) + } + } + } +} + +// With no core set the page still completes: Enter on the explicit-apps +// field submits it. +func TestFlatpaksFormWithoutCoreSetCompletes(t *testing.T) { + var inspected int + w := previewWizard(&inspected) + w.c.entry = CatalogEntry{Name: "floe", Ref: floeRef} + mustRefresh(t, w) + m := newPageModel(w, w.flatpaksForm()) + pumpKeys(t, m, keyEnter, keyEnter) + if m.form.State != huh.StateCompleted { + t.Fatalf("form state = %v, want completed; view:\n%s", m.form.State, m.View()) + } +} + +// An inspection that succeeds without readable metadata is unknown, not +// "no core set": the toggle stays and the next visit retries. +func TestCorePreviewUnreadableInspectionKeepsToggle(t *testing.T) { + r := runner.NewFake( + func(_ context.Context, name string, args ...string) ([]byte, error) { + if strings.HasPrefix(args[1], "docker://") { + return []byte("not json"), nil + } + return nil, errors.New("not cached") + }, + func(name string) (string, error) { return "/usr/bin/" + name, nil }, + ) + w := &wizard{opts: WizardOpts{Runner: r}} + w.c.entry = CatalogEntry{Name: "snow", Ref: snowRef} + w.c.coreFlatpaks = true + mustRefresh(t, w) + w.flatpaksForm() + if w.preview.state != corePreviewUnavailable || w.preview.ref != "" || !w.c.coreFlatpaks { + t.Fatalf("state = %v, ref = %q, coreFlatpaks = %v; want unavailable, retry, answer kept", + w.preview.state, w.preview.ref, w.c.coreFlatpaks) + } +} + +func TestWrapNames(t *testing.T) { + apps := []flatpak.CoreApp{{Name: "Alpha"}, {Name: "Beta"}, {Name: "Gamma"}} + if got, want := wrapNames(apps, 12), "Alpha, Beta,\nGamma"; got != want { + t.Fatalf("wrapNames = %q, want %q", got, want) + } + // Width counts display cells, not bytes: "Éditeur" is 7 cells, 8 bytes. + accented := []flatpak.CoreApp{{Name: "Éditeur"}, {Name: "Beta"}} + if got, want := wrapNames(accented, 13), "Éditeur, Beta"; got != want { + t.Fatalf("wrapNames(non-ASCII) = %q, want %q", got, want) + } +} + +func TestFirstLineStripsControlCharacters(t *testing.T) { + if got, want := firstLine("bad \x1b[2Jregistry\tsays\nsecond line"), "bad ?[2Jregistry?says"; got != want { + t.Fatalf("firstLine = %q, want %q", got, want) + } +} + +func mustRefresh(t *testing.T, w *wizard) { + t.Helper() + if quit, err := w.refreshCorePreview(context.Background()); quit || err != nil { + t.Fatalf("refreshCorePreview = quit %v, err %v", quit, err) + } +} + +// The wizard shows progress through showProgress, and an abort there quits +// without touching the previous preview. +func TestRefreshCorePreviewUsesProgressAndPropagatesQuit(t *testing.T) { + var inspected int + w := previewWizard(&inspected) + w.c.entry = CatalogEntry{Name: "snow", Ref: snowRef} + var titles []string + w.showProgress = func(ctx context.Context, title string, inspect func(context.Context) corePreview) (corePreview, bool, error) { + titles = append(titles, title) + return inspect(ctx), false, nil + } + mustRefresh(t, w) + if len(titles) != 1 || !strings.Contains(titles[0], "snow") || w.preview.state != corePreviewAvailable { + t.Fatalf("titles = %v, state = %v", titles, w.preview.state) + } + + w.c.entry = CatalogEntry{Name: "floe", Ref: floeRef} + w.showProgress = func(context.Context, string, func(context.Context) corePreview) (corePreview, bool, error) { + return corePreview{}, true, nil + } + quit, err := w.refreshCorePreview(context.Background()) + if !quit || err != nil { + t.Fatalf("aborted refresh = quit %v, err %v; want quit", quit, err) + } + if w.preview.ref != snowRef { + t.Fatalf("an aborted refresh replaced the preview: %+v", w.preview) + } +} + +func TestInspectingModel(t *testing.T) { + m := &inspectingModel{spin: spinner.New(), title: "Reading snow's core app list…", + inspect: func() corePreview { return corePreview{ref: snowRef, state: corePreviewNone} }} + if !strings.Contains(m.View(), "Reading snow's core app list") { + t.Fatalf("view = %q", m.View()) + } + // Keys typed while waiting are swallowed, not queued for the next page. + if _, cmd := m.Update(keyEnter); cmd != nil { + t.Fatalf("Enter while inspecting returned a command") + } + if _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}); cmd == nil { + t.Fatal("Ctrl-C while inspecting did not interrupt") + } else if _, ok := cmd().(tea.InterruptMsg); !ok { + t.Fatalf("Ctrl-C produced %T, want tea.InterruptMsg", cmd()) + } + msg := previewDoneMsg{m.inspect()} + _, cmd := m.Update(msg) + if !m.done || m.result.state != corePreviewNone || m.View() != "" { + t.Fatalf("after completion: done=%v result=%+v view=%q", m.done, m.result, m.View()) + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("completion produced %T, want tea.QuitMsg", cmd()) + } +} diff --git a/internal/tui/wizard.go b/internal/tui/wizard.go index fdae091..fc26f72 100644 --- a/internal/tui/wizard.go +++ b/internal/tui/wizard.go @@ -65,6 +65,8 @@ func RunWizard(ctx context.Context, o WizardOpts) ([]byte, error) { catalog: catalog, theme: huh.ThemeBase16(), // ANSI 16-color: safe on consoles and serial terminals secretsDir: o.SessionDir, + + showProgress: showInspecting, } return w.run(ctx) } @@ -76,6 +78,12 @@ type wizard struct { theme *huh.Theme secretsDir string c wizardChoices + // preview is the chosen image's core Flatpak set as last inspected by + // the flatpaks page; it is display-only and never enters the recipe. + preview corePreview + // showProgress draws progress while the preview inspection runs; nil + // (as in unit tests) runs the inspection inline. + showProgress progressFunc // backward is true while the user is navigating backward (Shift-Tab); // validators wrapped by skipWhenBacking then pass. It is set and cleared // by wizardPageModel. @@ -185,7 +193,9 @@ func (w *wizard) run(ctx context.Context) ([]byte, error) { case pageUser: quit, err = w.page(ctx, w.userForm()) case pageFlatpaks: - quit, err = w.page(ctx, w.flatpaksForm()) + if quit, err = w.refreshCorePreview(ctx); !quit && err == nil { + quit, err = w.page(ctx, w.flatpaksForm()) + } case pageReview: var startOver bool var reviewed []byte diff --git a/internal/tui/wizard_pages.go b/internal/tui/wizard_pages.go index 9727d30..3c82a69 100644 --- a/internal/tui/wizard_pages.go +++ b/internal/tui/wizard_pages.go @@ -459,12 +459,41 @@ func (w *wizard) userForm() *huh.Form { ) } +// flatpaksForm offers the chosen image's core app set according to +// w.preview (refreshed by the run loop before this page), plus extra apps. +// An image with no set, or a malformed one, gets an explanation instead of +// the toggle, and the toggle's value is cleared so the recipe never asks +// for a set the wizard did not offer. func (w *wizard) flatpaksForm() *huh.Form { - return huh.NewForm(huh.NewGroup( - huh.NewConfirm(). + var core huh.Field + switch w.preview.state { + case corePreviewNone: + w.c.coreFlatpaks = false + core = huh.NewNote(). + Title("No core app set"). + Description("This image publishes no core Flatpak apps.") + case corePreviewInvalid: + w.c.coreFlatpaks = false + core = huh.NewNote(). + Title("Core app set unavailable"). + Description(escapeMarkdown("This image's core app list is malformed, so it cannot be installed.\n" + w.preview.detail)) + case corePreviewAvailable: + core = huh.NewConfirm(). + Title("Install this image's core app set?"). + Description("Initially No. This image publishes these Flatpak apps:\n" + wrapNames(w.preview.apps, 64)). + Value(&w.c.coreFlatpaks) + default: + desc := "Initially No. This image's list could not be read now; it is read\nagain at install time." + if w.preview.detail != "" { + desc += "\n(" + w.preview.detail + ")" + } + core = huh.NewConfirm(). Title("Install this image's core app set?"). - Description("Initially No. The curated Flatpak set published for this image,\ninstalled offline from the installer medium where available."). - Value(&w.c.coreFlatpaks), + Description(desc). + Value(&w.c.coreFlatpaks) + } + return huh.NewForm(huh.NewGroup( + core, huh.NewText(). Title("Extra Flatpak apps (optional)"). Description("Application IDs, e.g. org.mozilla.firefox — comma, space, or\nnewline separated."). @@ -473,6 +502,12 @@ func (w *wizard) flatpaksForm() *huh.Form { )) } +// escapeMarkdown keeps note text literal: huh renders note descriptions as +// markdown, so paired underscores (core_flatpaks) would vanish. +func escapeMarkdown(s string) string { + return strings.NewReplacer("_", "\\_", "*", "\\*").Replace(s) +} + func (w *wizard) reviewForm(recipeTOML string, issues []recipe.Issue, action *string) *huh.Form { desc := recipeTOML if txt := renderIssues(issues); txt != "" { @@ -483,7 +518,7 @@ func (w *wizard) reviewForm(recipeTOML string, issues []recipe.Issue, action *st // the characters VANISH from the "review this exact recipe" screen // (observed live in the TUI E2E). Escape markdown emphasis so the // TOML shows byte-exact. - desc = strings.NewReplacer("_", "\\_", "*", "\\*").Replace(desc) + desc = escapeMarkdown(desc) return huh.NewForm(huh.NewGroup( huh.NewNote(). Title("Review — this exact recipe will be installed"). diff --git a/test/e2e-tui.sh b/test/e2e-tui.sh index 18bcab8..b195dfd 100755 --- a/test/e2e-tui.sh +++ b/test/e2e-tui.sh @@ -265,9 +265,13 @@ type_input 'Confirm password' 'firn-e2e-pw' accept_field '^.*Groups' # sudo preselected accept_field 'Additional groups' accept_field 'User SSH authorized key' # empty huh.NewText -# Flatpaks: core-set confirm + IDs. The schema and TUI both default this -# optional feature to false, so Enter preserves the focused No answer. -accept_field 'core app set' # No (floe has no runtime) +# Flatpaks: the page inspects the chosen image's org.frostyard.core-flatpaks +# label (ADR-0018) before rendering, bounded by a 30s timeout. floe publishes +# no core set, so the wizard explains that instead of offering the toggle, +# and the explicit-apps field is the only input. +expect_screen 'publishes no core Flatpak apps' 90 +cap | grep -Eq "Install this image's core app set" && + fail "core app toggle offered for floe, which publishes no core set" accept_field 'Extra Flatpak apps' # empty huh.NewText # Review: with a long recipe the page TITLE scrolls off the 24-row pane; # gate on the action list, which is always visible at the bottom.