From 5a3437669fd05ca25c2df99d5a04feda366f8e76 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 30 Sep 2026 22:29:33 +0200 Subject: [PATCH] =?UTF-8?q?v0.284.0=20=E2=80=94=20a=20box=20deletes=20old?= =?UTF-8?q?=20app=20images=20(decision=2053,=20R-736);=20an=20after=5Finst?= =?UTF-8?q?all=20app=20is=20held=20until=20its=20known=20login=20is=20repl?= =?UTF-8?q?aced=20(R-741)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image retention: after a done/undone guarded Update and at remove, an app's images older than its running and previous one are deleted — never an image any container, installed compose or installed/previous record names (box-wide keep set read at delete time); exact id, never forced or pruned; paused while any update runs; a one-time sweep of catalog app images at the first start. Install hold: an after_install app is installed behind the setup gate's door and opens when after_install succeeds or the household says it changed the login. Tests TestImageRetention_* and TestInstallHold_* with red-proofs; parity fixture for the held card. MinAgent: 0.131.0 (unchanged). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 30 + CONTEXT.md | 7 +- REPORT.md | 25 +- REUSE.md | 2 + controller/README.md | 8 + controller/cmd/controller/main.go | 10 + controller/internal/i18n/locales/en.json | 2 + controller/internal/i18n/locales/hu.json | 2 + controller/internal/stacks/after_install.go | 3 + controller/internal/stacks/delete.go | 4 + controller/internal/stacks/deploy.go | 21 + controller/internal/stacks/image_retention.go | 353 +++++++++++ .../internal/stacks/image_retention_test.go | 244 ++++++++ controller/internal/stacks/install_hold.go | 211 +++++++ .../internal/stacks/install_hold_test.go | 198 ++++++ controller/internal/stacks/life_records.go | 1 + controller/internal/stacks/setup_gate.go | 14 + controller/internal/stacks/undo.go | 1 + controller/internal/stacks/update.go | 22 +- controller/internal/web/handlers.go | 2 + controller/internal/web/i18n_parity_test.go | 11 + .../internal/web/templates/app_info.html | 6 + .../i18n_parity/app_info_install_hold.html | 570 ++++++++++++++++++ 23 files changed, 1724 insertions(+), 23 deletions(-) create mode 100644 controller/internal/stacks/image_retention.go create mode 100644 controller/internal/stacks/image_retention_test.go create mode 100644 controller/internal/stacks/install_hold.go create mode 100644 controller/internal/stacks/install_hold_test.go create mode 100644 controller/internal/web/testdata/i18n_parity/app_info_install_hold.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 5343659..09253e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## v0.284.0 — a box deletes old app images (decision 53); an after_install app is held until its known login is replaced (R-741) (2026-09-30) + +**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: `app_info.install_hold_title`, +`app_info.install_hold_closed` (hu + en). + +- **Image retention — `09` §3 decision 53 (R-736).** A remove ran `compose down --rmi local`, which never removes a + registry-pulled image; an update left the old version's image; nothing else deleted any (9202: 84 images, 53 GB + used by no container, an install refused). Now (`stacks/image_retention.go`): when a guarded Update ends `done`, + `previous_images` records what the app ran before; the app's images older than the running one and that previous one + are deleted. At `undone` the attempt's image goes. At remove the app's images go. **Never** an image any container + (running or stopped) uses, any installed app's live compose names, or any installed app's installed/previous record + names — a box-wide keep set read at delete time; deletion by exact image id, never forced, never `prune`; an id + with several repositories' names is left alone; a keep set that cannot be read deletes nothing; **no pass runs while + any update runs** (its undo's image is named by nothing then). One line per deletion (names, id, size). A one-time + sweep at the first start after this release (3 min after start, a marker file) applies the rule to every image the + catalog names, so the images of apps removed before this release go too; never the controller's or infrastructure's. + Tests `TestImageRetention_*` (6); red-proofs: the undo's image, a stopped app's compose, the update-in-flight pause, + the unreadable keep set, the shared engine image. +- **The install hold — R-741 (decision 45).** Measured 2026-09-30: calibre-web answered its public default login + through traefik for 1–18 s after a fresh install, before `after_install` replaced it. Now an `after_install:` app is + installed HELD (`stacks/install_hold.go`): before its first start a traefik file puts the setup gate's forwardAuth + door in front of every router it publishes, at a priority above the gate and the sign-up block. A stranger is refused; + the household (dashboard session) passes, so a failed `after_install` leaves it able to change the login by hand and + press "I changed it", which opens the hold. `after_install` succeeding opens it (record, then file). The gate loop + reconciles: stale files go, a record that says the login was replaced opens, an install whose hook a restart cut off + re-runs `after_install` once (only installs older than the process). The app page shows a card while held. + Tests `TestInstallHold_*` (6); red-proofs RP-IH1..4 (the file before the first start, the open on success, the + household's word, the re-run only after a restart). Found while testing: `DeployedAt` has whole seconds, so the + comparison uses the process start truncated to the second. + ## v0.283.1 — a Stop holds during the nightly volume dump IN PRODUCTION, and at the crash recovery (R-721) (2026-09-30) **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). No new strings. diff --git a/CONTEXT.md b/CONTEXT.md index 5e1e471..4f903f9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,12 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-09-30 (v0.283.0 + v0.283.1 — apps off-site by default, Stop holds during a backup) +Last updated: 2026-09-30 late evening (v0.284.0 — image retention, the install hold) + +> **2026-09-30 late — v0.284.0.** Operator rulings: `09` §3 decision 53 (R-736 A: keep running + previous image per +> service, delete older, never an image a container/compose/record names) → `stacks/image_retention.go`, with a +> one-time sweep at start; R-741 → `stacks/install_hold.go` (after_install apps held behind the gate's door until the +> login is replaced). Decision 52 (same-tag re-tests) needed NO controller change (measured by a unit walk). > **2026-09-30 — v0.283.0 / v0.283.1.** Decision 50: `settings.DefaultOffboxOnForNewApp` from the deploy-done hook > (an earlier per-app choice wins); one press for older apps; `backup/offbox_fit.go` size card (estimate at each run diff --git a/REPORT.md b/REPORT.md index 1fde401..20ea688 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,17 +1,10 @@ -# REPORT — 2026-09-30: v0.283.0 + v0.283.1 +# REPORT — v0.284.0 (2026-09-30 late evening) -Full session report: `felhom.eu/REPORT-fixes-first-tester-2026-09-30.md`. Evidence: -`felhom.eu/documentation/audits/evidence-fixes-first-tester-2026-09-30/`. - -- **v0.283.0 — decision 50 (R-720):** a fresh install joins the off-site copy when the customer has off-site; one - press for older apps on both backup pages; the size card over the quota. Measured first: over the quota nothing - but the ruled retention runs — pinned by `TestDecision50_OverQuotaDeletesNothingExtra`. -- **R-721:** a Stop pressed while a machine has the app down holds — quiesce resume, volume dump, update leg (v0.283.0), - the dump's PRODUCTION adapter and the startup crash recovery (v0.283.1, after the live test on 9202 caught v0.283.0 - restarting the app 8 s after the Stop). -- **R-724 / R-725 (box half):** real schedule and local times on Beállítások and the dashboard; „az előző N órája - készült"; the restore-test card names its tier; the recovery wizard speaks „te" (formal ceiling 18 → 17). -- Red-proofs RP31–RP38, RP43, RP44. Parity: two new cases (`backups_remote_offsite_offer_fit`, - `backups_apps_offsite_offer`); four fixtures re-captured, diff = the intended lines only. -- Live: 9202 (press, fresh app ON, Stop during the dump holds on 0.283.1); both demo boxes on 0.283.1 by the floor. -- MinAgent 0.131.0 (unchanged). No golden this session (see STATUS: the operator's choice before Tester-2's install). +- **Image retention (`09` §3 decision 53, R-736):** after a done/undone guarded Update and at remove, an app's older + images are deleted; the box-wide keep set (containers, installed composes, installed/previous records) is read at + delete time; exact id, never forced/pruned; paused while any update runs; a one-time sweep 3 min after start. +- **Install hold (R-741):** an `after_install` app is held behind the setup gate's door from its first start until the + known login is replaced (after_install, or the household's "I changed it"). +- Tests: `TestImageRetention_*` (6), `TestInstallHold_*` (6), parity fixture `app_info_install_hold`; red-proofs in + `felhom.eu/documentation/audits/night-rulings-2026-09-30/{B,C}/`. Green gate: build, vet, test ./... rc=0. +- Evidence and the live proofs: `felhom.eu/documentation/audits/night-rulings-2026-09-30/`. diff --git a/REUSE.md b/REUSE.md index b0f6a17..65a2092 100644 --- a/REUSE.md +++ b/REUSE.md @@ -28,6 +28,8 @@ | `stacks.RunAfterInstall` / `expandAfterInstall` / `web.defaultLoginInEffect` (v0.279.0, decision 45) | controller/internal/stacks/after_install.go · controller/internal/web/known_login.go | `(name, wait)` / `(cmd, allowed, env)` / `(meta, cfg, installed)` | A fresh install replaces a known default login; the page says when a default is still in effect | **Only from the deploy-done hook** — never after a restore/kept load (R-694). A `success:` marker is required (exit 0 lies). Never log the expanded command | | `stacks.OpenSetupGate` / `SetupGateTick` / `SetupGateHost` · `web.ServeGateAuth` / `ServeGateStart` (v0.280.0, decision 46) | controller/internal/stacks/setup_gate.go · controller/internal/web/setup_gate.go | `(name, by)` / `()` / `(host)` · handlers | The setup gate: a `setup_gate: true` install is closed to everyone but the household until its probe or the household's press opens it | **Write the gate BEFORE the first start** (spike F2). Open = record first, then remove the file. Never widen the dashboard cookie — the handshake mints a host-bound one-use token | | `stacks.OpenSignupWindow` / `SignupBlocked` / `SetupGateProbe` · `web.ServeSignupClosed` (v0.281.0, decision 47) | controller/internal/stacks/signup_block.go · controller/internal/web/setup_gate.go | `(name)` | Sign-up closed at the app's own address once the gate opens; the household's 15-minute window; the press asks the probe | **The block goes up BEFORE the gate comes down** (a failed write keeps the gate closed). Never on an app this box did not gate | +| `stacks.OpenInstallHold` / `installHoldTick` (v0.284.0, R-741) | controller/internal/stacks/install_hold.go | `(name, by)` / `()` | An `after_install` app held behind the setup gate's door until its known login is replaced | **Written before the first start**, like the gate; opens on `after_install` success or the household's "I changed it"; the door (`SetupGateHost`) reads holds first | +| `stacks.RetainImagesAfterUpdate` / `RetainImagesAfterRemove` / `RunImageRetentionOnce` · seam `imageDocker` (v0.284.0, decision 53) | controller/internal/stacks/image_retention.go | `(name, previous)` / `(name, repos)` / `()` | Deletes an app's images older than its running + previous one | **The keep set is box-wide and read at delete time** (containers, installed composes, installed/previous records); exact id, never forced or pruned; skipped while any update runs; tests use the `imageDocker` seam, never Docker | | `stacks.CloseSignupNow` / `CloseSignupOffered` / `applyNativeLock` (v0.282.0, decisions 47/49) | controller/internal/stacks/after_setup.go | `(name)` | The app's own sign-up switch after the setup; "close sign-up now" for an app installed before the rule | **Check the installed compose reads the variable** (an old install carries the old compose until its next update) — never record a lock that is not there. One run per app at a time (`nativeLockBusy`) | | `backup.judgeCopy` / `HollowCopies` / `SetHollowCopyNotify` (Part D, v0.279.0) | controller/internal/backup/hollow_watch.go | `(app, tier, unitDir)` | A RUNNING app whose newest copy holds no data → operator digest once/day + page sentence | Uses `unitCarriesData` (the manifest, never size); a stopped held app is never flagged | | `web.nightChain` (R-705, v0.279.0) | controller/internal/web/night_chain.go | `POST /api/debug/backup/night-chain` | The night's four legs now, in order | Refuses while any op/update/chain runs; the leg uses `RunUpdateLegNow` | diff --git a/controller/README.md b/controller/README.md index 32800da..7ec3911 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1931,6 +1931,14 @@ that folder is never a dead end, and an install never runs into it silently (R-6 **v0.280.0 (R-710):** an absent record means "not run yet" only for 30 minutes after the install (an app installed before its template gained the command is warned), and the card has "I changed it" (`POST /apps//default-login/changed` → `app.yaml` `default_login`), after which the card goes. +- **The install hold (v0.284.0, R-741)** — an app with `after_install:` is installed HELD: the setup gate's forwardAuth + door stands in front of its routers (`install-hold-.yml`, priority above the gate) until `after_install` succeeds + or the household says it changed the login; app.yaml `install_hold` (the gate's record shape). A stranger is refused; + the household passes. See `internal/stacks/install_hold.go`. +- **Image retention (v0.284.0, decision 53)** — after a guarded Update and at remove, an app's older images are deleted: + kept are every container's image, every installed compose's, and each installed app's running + `previous_images`. + By exact id, never forced or pruned; no pass while any update runs; a one-time sweep at the first start after the + release (marker `image-retention-v1.done` in the data dir). See `internal/stacks/image_retention.go`. - **The setup gate (v0.280.0, decision 46)** — `.felhom.yml` `setup_gate: true` + optional `setup_done_probe: {url, field, done}`. A FRESH install is closed to everyone but the household: the traefik file `/traefik/dynamic/setup-gate-.yml` is written BEFORE the first start (a failed write refuses the install) and diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 8ec6556..fd9d726 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1687,6 +1687,16 @@ func main() { // v0.280.0 (`09` §3 decision 46): the setup gate's loop — every closed gate's traefik file exists, a probe // that says "set up" opens its gate, and a gate file nobody owns is removed. go stackMgr.RunSetupGateLoop(ctx, 20*time.Second) + // decision 53 (R-736): the one-time image clean-up for a box older than the rule — after the first catalog sync + // has had time to land (it reads the catalog's image names), once per box (a marker file). + go func() { + select { + case <-ctx.Done(): + return + case <-time.After(3 * time.Minute): + } + stackMgr.RunImageRetentionOnce() + }() // --- Initialize API router --- apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, updater, notifier, logger) diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 8a59a4e..394200a 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2473,6 +2473,8 @@ "setup_gate.sign_in": "Sign in", "app_info.setup_gate_title": "First setup", "app_info.setup_gate_closed": "Right now only you can reach this app, while you are signed in to the dashboard. So nobody else can create its first admin account. Open it and finish the first setup.", + "app_info.install_hold_title": "Only you can reach it for now", + "app_info.install_hold_closed": "The box is replacing this app's known first password with a generated one of its own. Until then only you can reach it, while you are signed in to the dashboard – nobody else can sign in with the known password. If the change fails, change the password by hand and say so here.", "app_info.setup_gate_probe": "When you are done, the box notices it by itself and opens the app for everyone.", "app_info.setup_gate_button_hint": "When you are done, press this button. Until then, phone apps and the rest of your family cannot reach it.", "app_info.setup_gate_done_btn": "Done, I set it up", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index eb86964..cfc2b73 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2461,6 +2461,8 @@ "setup_gate.sign_in": "Bejelentkezés", "app_info.setup_gate_title": "Első beállítás", "app_info.setup_gate_closed": "Most csak te éred el ezt az alkalmazást, amíg be vagy jelentkezve a vezérlőpultba. Így más nem hozhatja létre az első admin fiókot. Nyisd meg, és végezd el az első beállítást.", + "app_info.install_hold_title": "Még csak te éred el", + "app_info.install_hold_closed": "A doboz most cseréli le az alkalmazás ismert első jelszavát egy saját, generált jelszóra. Addig csak te éred el, amíg be vagy jelentkezve a vezérlőpultba – más nem léphet be az ismert jelszóval. Ha a csere nem sikerül, változtasd meg a jelszót kézzel, és jelezd itt, hogy megtetted.", "app_info.setup_gate_probe": "Ha kész, a doboz magától észreveszi, és mindenkinek megnyitja az alkalmazást.", "app_info.setup_gate_button_hint": "Ha kész, nyomd meg ezt a gombot. Addig a telefonos alkalmazások és a család többi tagja nem éri el.", "app_info.setup_gate_done_btn": "Kész, beállítottam", diff --git a/controller/internal/stacks/after_install.go b/controller/internal/stacks/after_install.go index ebbf936..553a1f9 100644 --- a/controller/internal/stacks/after_install.go +++ b/controller/internal/stacks/after_install.go @@ -181,6 +181,9 @@ func (m *Manager) runAfterInstallNow(name string, ai *AfterInstallCommand, cmd [ m.logger.Printf("[INFO] [stacks] after_install %s: %s %v done in %s (try %d) — the app's known first login is replaced by its generated one", name, ai.Service, ai.Command, time.Since(t0).Round(time.Millisecond), try) record(true, "") + if err := m.OpenInstallHold(name, InstallHoldByAfterInstall); err != nil { + m.logger.Printf("[ERROR] [stacks] %s: %v — the loop retries", name, err) + } return nil } last = fmt.Sprintf("try %d: err=%v, no %q in the output", try, err, ai.Success) diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 7c70e64..ff4842f 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -285,6 +285,9 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, } } + // decision 53 (R-736): the app's image repositories, read BEFORE the remove (its compose and records go). + removedRepos := appImageRepos(stackDir, LoadAppConfig(stackDir)) + // Step 2: Run docker compose down --rmi local --volumes // H14: Return error if docker compose down fails — continuing would leave orphaned containers. env := m.stackEnv(stackDir) @@ -364,6 +367,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, if err := m.ScanStacks(); err != nil { m.logger.Printf("[WARN] Rescan after delete failed: %v", err) } + go m.RetainImagesAfterRemove(name, removedRepos) // decision 53 (R-736): the removed app's images, if nothing keeps them return resp, nil } diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index fb021fa..c947b5f 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -184,6 +184,12 @@ type AppConfig struct { // SetupGate (v0.280.0, decision 46) is the app's setup gate: closed from a fresh install until the first // setup is done. A life record (carried across a restore). See setup_gate.go. SetupGate *SetupGateRecord `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"` + // InstallHold (R-741, decision 45): an after_install app is held (the gate's door) from its fresh install until + // its known first login is replaced. Same record shape as SetupGate. See install_hold.go. + InstallHold *SetupGateRecord `yaml:"install_hold,omitempty" json:"install_hold,omitempty"` + // PreviousImages (R-736, decision 53): per service, the image the app ran BEFORE its last done update — kept on + // the box with the running one; older images of the app are deleted (image_retention.go). + PreviousImages map[string]InstalledImage `yaml:"previous_images,omitempty" json:"previous_images,omitempty"` // DefaultLogin (v0.280.0, R-710) is the household's own word that it changed the template's known default // login by hand. The page stops naming the default. See internal/web/known_login.go. DefaultLogin *DefaultLoginRecord `yaml:"default_login,omitempty" json:"default_login,omitempty"` @@ -434,6 +440,20 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { } gate = g } + // R-741: an after_install template is installed HELD, the file written before the first start, like the gate. + var hold *SetupGateRecord + if wantsInstallHold(&meta) { + h, err := m.prepareInstallHold(req.StackName, stack.ComposePath, env) + if err != nil { + clearDeploying() + if gate != nil { + _ = m.removeSetupGateFile(req.StackName) + } + m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: the install hold could not be prepared: %v", req.StackName, err) + return "", util.MsgError("err.stacks.setup_gate_failed", err.Error()) + } + hold = h + } // Save app.yaml. // CTRL-T2-1: persist the env now, but mark the ON-DISK state Deployed:false @@ -455,6 +475,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { // reverts Deployed to false — so a failed deploy can never present as an app owed a restart. DesiredState: DesiredStateRunning, SetupGate: gate, + InstallHold: hold, } diskCfg := *appCfg diff --git a/controller/internal/stacks/image_retention.go b/controller/internal/stacks/image_retention.go new file mode 100644 index 0000000..c408f88 --- /dev/null +++ b/controller/internal/stacks/image_retention.go @@ -0,0 +1,353 @@ +package stacks + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec" +) + +// ── Image retention (R-736, `09` §3 decision 53) ────────────────────────────────────────────────────── +// +// A remove ran `compose down --rmi local` (which never removes a registry-pulled image) and an update left the old +// version's image behind; nothing else deleted any. On scratch guest 9202 that filled the Docker disk until the box +// refused an install (2026-09-30: 84 images, 53 GB used by no container). The ruling: a box keeps, per app service, +// the image it runs now and the image before it (the undo's); it deletes older images of that app by itself; it +// NEVER deletes an image that any container (running or stopped) or any installed app's compose still names. +// Removing an app deletes that app's images under the same rule. Kept data (decision 40) is data, not images. +// +// THE KEEP SET is box-wide and rebuilt at every pass, at delete time: every container's image ID, every image an +// installed app's live compose names (by tag and by digest), and every installed app's installed_images and +// previous_images. A CANDIDATE is an image whose repository is one of THIS app's service repositories and whose ID +// is not kept. It is deleted by exact ID, never forced (Docker itself refuses an image a container uses) and never +// by prune; an ID that carries several repositories' tags is left alone. Every deletion is logged with its size. +// Pinned by internal/stacks/image_retention_test.go. + +// imageDocker runs one docker command (a seam: tests never reach Docker). +var imageDocker = func(args ...string) (string, error) { + out, err := dockerexec.Command("docker", args...).CombinedOutput() + return string(out), err +} + +var imageRetentionMu sync.Mutex + +type localImage struct { + ID, Repo, Tag, Digest, Size string +} + +func splitRepoTag(ref string) (repo, tag, digest string) { + if i := strings.Index(ref, "@"); i >= 0 { + ref, digest = ref[:i], ref[i+1:] + } + if c := strings.LastIndex(ref, ":"); c > strings.LastIndex(ref, "/") { + return ref[:c], ref[c+1:], digest + } + return ref, "latest", digest +} + +// normRepo makes Docker Hub's short forms comparable: "library/postgres" and "docker.io/postgres" are "postgres". +func normRepo(r string) string { + r = strings.TrimPrefix(r, "docker.io/") + return strings.TrimPrefix(r, "library/") +} + +func listLocalImages() ([]localImage, error) { + out, err := imageDocker("image", "ls", "--digests", "--no-trunc", "--format", "{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Digest}}\t{{.Size}}") + if err != nil { + return nil, fmt.Errorf("docker image ls: %v: %s", err, truncateStr(out, 200)) + } + var imgs []localImage + for _, l := range strings.Split(strings.TrimSpace(out), "\n") { + f := strings.Split(l, "\t") + if len(f) < 5 || f[0] == "" { + continue + } + imgs = append(imgs, localImage{ID: f[0], Repo: normRepo(f[1]), Tag: f[2], Digest: f[3], Size: f[4]}) + } + return imgs, nil +} + +func imagesUsedByContainers() (map[string]bool, error) { + out, err := imageDocker("ps", "-a", "-q", "--no-trunc") + if err != nil { + return nil, fmt.Errorf("docker ps: %v", err) + } + ids := strings.Fields(out) + used := map[string]bool{} + if len(ids) == 0 { + return used, nil + } + out, err = imageDocker(append([]string{"inspect", "--format", "{{.Image}}"}, ids...)...) + if err != nil { + // a container removed between the two calls fails the inspect: FAIL CLOSED — nothing is deleted + return nil, fmt.Errorf("docker inspect containers: %v", err) + } + for _, id := range strings.Fields(out) { + used[id] = true + } + return used, nil +} + +// matchImages: the local image IDs a reference names — by repo+tag, or by repo+digest. +func matchImages(imgs []localImage, ref, digest string) []string { + repo, tag, d := splitRepoTag(ref) + repo = normRepo(repo) + if digest == "" { + digest = d + } + var ids []string + for _, im := range imgs { + if im.Repo != repo { + continue + } + if (tag != "" && im.Tag == tag) || (digest != "" && im.Digest == digest) { + ids = append(ids, im.ID) + } + } + return ids +} + +// imageKeepSet is the box-wide keep set (see the header). except = an app being removed (its records do not keep). +func (m *Manager) imageKeepSet(imgs []localImage, except string) (map[string]bool, error) { + keep, err := imagesUsedByContainers() + if err != nil { + return nil, err + } + m.mu.RLock() + type app struct { + dir string + installed map[string]InstalledImage + previous map[string]InstalledImage + } + var apps []app + for n, st := range m.stacks { + if n == except || !st.Deployed { + continue + } + a := app{dir: filepath.Dir(st.ComposePath)} + if st.AppConfig != nil { + a.installed, a.previous = st.AppConfig.InstalledImages, st.AppConfig.PreviousImages + } + apps = append(apps, a) + } + m.mu.RUnlock() + for _, a := range apps { + if refs, err := ParseComposeImages(ComposePathIn(a.dir)); err == nil { + for _, ref := range refs { + for _, id := range matchImages(imgs, ref, "") { + keep[id] = true + } + } + } + for _, set := range []map[string]InstalledImage{a.installed, a.previous} { + for _, ii := range set { + for _, id := range matchImages(imgs, ii.Ref, ii.Digest) { + keep[id] = true + } + } + } + } + return keep, nil +} + +// appImageRepos: the repositories an app's services use (its live compose, its records). +func appImageRepos(dir string, cfg *AppConfig) map[string]bool { + repos := map[string]bool{} + if refs, err := ParseComposeImages(ComposePathIn(dir)); err == nil { + for _, r := range refs { + rp, _, _ := splitRepoTag(r) + repos[normRepo(rp)] = true + } + } + if cfg != nil { + for _, set := range []map[string]InstalledImage{cfg.InstalledImages, cfg.PreviousImages} { + for _, ii := range set { + rp, _, _ := splitRepoTag(ii.Ref) + repos[normRepo(rp)] = true + } + } + } + return repos +} + +// deleteUnkeptImages deletes every image of repos whose ID is not kept. Returns what it deleted. +func (m *Manager) deleteUnkeptImages(why string, repos map[string]bool, except string) ([]string, error) { + imageRetentionMu.Lock() + defer imageRetentionMu.Unlock() + // An update IN FLIGHT has already replaced its containers and its compose; the image its undo needs is then named + // by nothing the keep set reads. So no pass runs while any update runs (the next pass catches up). + m.mu.RLock() + busy := "" + for n, st := range m.stacks { + if st.Updating { + busy = n + break + } + } + m.mu.RUnlock() + if busy != "" { + m.logger.Printf("[INFO] [stacks] image retention (%s): skipped — %s is updating (its undo may need an image nothing else names)", why, busy) + return nil, nil + } + imgs, err := listLocalImages() + if err != nil { + return nil, err + } + keep, err := m.imageKeepSet(imgs, except) + if err != nil { + m.logger.Printf("[WARN] [stacks] image retention (%s): the keep set could not be read (%v) — NOTHING is deleted", why, err) + return nil, err + } + byID := map[string][]localImage{} + for _, im := range imgs { + byID[im.ID] = append(byID[im.ID], im) + } + ids := make([]string, 0, len(byID)) + for id := range byID { + ids = append(ids, id) + } + sort.Strings(ids) + var deleted []string + for _, id := range ids { + group := byID[id] + if keep[id] { + continue + } + inRepos, names := true, []string{} + for _, im := range group { + if !repos[im.Repo] || strings.Contains(im.Repo, "felhom-controller") { + inRepos = false + } + names = append(names, im.Repo+":"+im.Tag) + } + if !inRepos { + continue + } + if len(uniqueRepos(group)) > 1 { + m.logger.Printf("[INFO] [stacks] image retention (%s): %s carries several repositories' names %v — left alone", why, shortID(id), names) + continue + } + if out, err := imageDocker("rmi", id); err != nil { + m.logger.Printf("[WARN] [stacks] image retention (%s): docker refused to delete %v (%s): %s", why, names, shortID(id), truncateStr(strings.TrimSpace(out), 160)) + continue + } + m.logger.Printf("[INFO] [stacks] image retention (%s): deleted %v (%s, %s) — no container, installed app or undo names it (decision 53)", why, names, shortID(id), group[0].Size) + deleted = append(deleted, strings.Join(names, ",")) + } + return deleted, nil +} + +func uniqueRepos(g []localImage) map[string]bool { + r := map[string]bool{} + for _, im := range g { + r[im.Repo] = true + } + return r +} + +func shortID(id string) string { + id = strings.TrimPrefix(id, "sha256:") + if len(id) > 12 { + return id[:12] + } + return id +} + +// RetainImagesAfterUpdate is called when a guarded Update ends. previous = what the app ran BEFORE the update when it +// ended done (the image before the new one); when it was undone, the images of the attempt (the app runs the old +// ones again and the attempt is the most recent other image). It records previous_images, then deletes the app's +// older images. +func (m *Manager) RetainImagesAfterUpdate(name string, previous map[string]InstalledImage) { + st, ok := m.GetStack(name) + if !ok || !st.Deployed { + return + } + dir := filepath.Dir(st.ComposePath) + if len(previous) > 0 { + m.mutateAppConfig(name, dir, "previous_images", func(cfg *AppConfig) bool { + cfg.PreviousImages = previous + return true + }) + if err := m.ScanStacks(); err != nil { + m.logger.Printf("[WARN] [stacks] image retention %s: rescan failed: %v", name, err) + } + } + st, _ = m.GetStack(name) + if _, err := m.deleteUnkeptImages("update of "+name, appImageRepos(dir, st.AppConfig), ""); err != nil { + m.logger.Printf("[WARN] [stacks] image retention after the update of %s: %v", name, err) + } +} + +// retainAfterUpdateFn runs the retention after an update ends (a seam: the update tests do not exercise it). +var retainAfterUpdateFn = func(m *Manager, name string, previous map[string]InstalledImage) { + go m.RetainImagesAfterUpdate(name, previous) +} + +func (m *Manager) retainAfterUpdate(name string, previous map[string]InstalledImage) { + retainAfterUpdateFn(m, name, previous) +} + +// RetainImagesAfterRemove deletes a removed app's images (its repos, read BEFORE the remove) that nothing else keeps. +func (m *Manager) RetainImagesAfterRemove(name string, repos map[string]bool) { + if len(repos) == 0 { + return + } + if _, err := m.deleteUnkeptImages("remove of "+name, repos, name); err != nil { + m.logger.Printf("[WARN] [stacks] image retention after the remove of %s: %v", name, err) + } +} + +// catalogImageRepos: every repository any catalog template or step names (the one-time sweep's reach: app images +// only — never the controller's, traefik's or another infrastructure image). +func (m *Manager) catalogImageRepos() map[string]bool { + repos := map[string]bool{} + root := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates") + _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !(strings.HasSuffix(p, "docker-compose.yml") || (strings.Contains(p, string(filepath.Separator)+"steps"+string(filepath.Separator)) && strings.HasSuffix(p, ".yml") && !strings.HasSuffix(p, ".felhom.yml"))) { + return nil + } + if refs, err := ParseComposeImages(p); err == nil { + for _, r := range refs { + rp, _, _ := splitRepoTag(r) + repos[normRepo(rp)] = true + } + } + return nil + }) + return repos +} + +// imageRetentionMarker: the one-time sweep runs once per box (decision 53's clean-up for boxes older than it). +func (m *Manager) imageRetentionMarker() string { + return filepath.Join(m.cfg.Paths.DataDir, "image-retention-v1.done") +} + +// RunImageRetentionOnce is the one-time clean-up at the first start of this release: the same rule, applied to every +// app image the catalog names (so the images of apps removed before this release go too). Logged; a marker file +// keeps it to once. Returns what it deleted. +func (m *Manager) RunImageRetentionOnce() []string { + if _, err := os.Stat(m.imageRetentionMarker()); err == nil { + return nil + } + repos := m.catalogImageRepos() + if len(repos) == 0 { + m.logger.Printf("[WARN] [stacks] image retention (one-time): no catalog read — skipped, tried again at the next start") + return nil + } + before, _ := imageDocker("system", "df", "--format", "{{.Type}} {{.Size}} {{.Reclaimable}}") + deleted, err := m.deleteUnkeptImages("one-time clean-up", repos, "") + if err != nil { + m.logger.Printf("[WARN] [stacks] image retention (one-time): %v — tried again at the next start", err) + return nil + } + after, _ := imageDocker("system", "df", "--format", "{{.Type}} {{.Size}} {{.Reclaimable}}") + m.logger.Printf("[INFO] [stacks] image retention (one-time): deleted %d image(s). docker disk before: %s | after: %s", + len(deleted), strings.Join(strings.Fields(firstLine(before)), " "), strings.Join(strings.Fields(firstLine(after)), " ")) + _ = os.MkdirAll(filepath.Dir(m.imageRetentionMarker()), 0o755) + _ = os.WriteFile(m.imageRetentionMarker(), []byte(fmt.Sprintf("deleted %d\n%s\n", len(deleted), strings.Join(deleted, "\n"))), 0o644) + return deleted +} diff --git a/controller/internal/stacks/image_retention_test.go b/controller/internal/stacks/image_retention_test.go new file mode 100644 index 0000000..284cbaa --- /dev/null +++ b/controller/internal/stacks/image_retention_test.go @@ -0,0 +1,244 @@ +package stacks + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// R-736 (decision 53): the box keeps each app service's running image and the one before it; it deletes older +// images of that app; it never deletes an image a container or an installed compose names. Docker is the +// imageDocker seam — nothing here reaches a daemon (and dockerexec refuses one under go test anyway, R-650). + +type fakeImages struct { + imgs []localImage + containers map[string]string // container id -> image id + rmi []string +} + +func (f *fakeImages) run(args ...string) (string, error) { + switch { + case args[0] == "image" && args[1] == "ls": + var b strings.Builder + for _, im := range f.imgs { + fmt.Fprintf(&b, "%s\t%s\t%s\t%s\t%s\n", im.ID, im.Repo, im.Tag, im.Digest, im.Size) + } + return b.String(), nil + case args[0] == "ps": + var ids []string + for c := range f.containers { + ids = append(ids, c) + } + sort.Strings(ids) + return strings.Join(ids, "\n"), nil + case args[0] == "inspect": + var out []string + for _, c := range args[3:] { + out = append(out, f.containers[c]) + } + return strings.Join(out, "\n"), nil + case args[0] == "rmi": + f.rmi = append(f.rmi, args[1]) + var keep []localImage + for _, im := range f.imgs { + if im.ID != args[1] { + keep = append(keep, im) + } + } + f.imgs = keep + return "Deleted", nil + case args[0] == "system": + return "Images 1GB 0B", nil + } + return "", fmt.Errorf("unexpected docker %v", args) +} + +func withFakeImages(t *testing.T, f *fakeImages) { + t.Helper() + prev := imageDocker + imageDocker = f.run + t.Cleanup(func() { imageDocker = prev }) +} + +// retentionManager: two installed apps sharing postgres:18-alpine; app "web" at web:3 (previous web:2), web:1 older. +func retentionManager(t *testing.T) *Manager { + t.Helper() + m := gateManager(t, "display_name: G\n") + m.cfg.Paths.DataDir = filepath.Join(t.TempDir(), "data") // never the package folder + root := m.cfg.Paths.StacksDir + write := func(app, compose, appYaml string) { + d := filepath.Join(root, app) + must(t, os.MkdirAll(d, 0o755)) + must(t, os.WriteFile(filepath.Join(d, "docker-compose.yml"), []byte(compose), 0o644)) + must(t, os.WriteFile(filepath.Join(d, "app.yaml"), []byte(appYaml), 0o644)) + } + write("web", "services:\n web:\n image: acme/web:3\n web-db:\n image: postgres:18-alpine\n", + "deployed: true\nenv: {}\ninstalled_images:\n web:\n ref: acme/web:3\n digest: sha256:w3\n at: \"2026-09-30T00:00:00Z\"\n web-db:\n ref: postgres:18-alpine\n digest: sha256:p18\n at: \"2026-09-30T00:00:00Z\"\nprevious_images:\n web:\n ref: acme/web:2\n digest: sha256:w2\n at: \"2026-09-20T00:00:00Z\"\n") + write("docs", "services:\n docs:\n image: acme/docs:1\n docs-db:\n image: postgres:18-alpine\n", + "deployed: true\nenv: {}\ninstalled_images:\n docs:\n ref: acme/docs:1\n digest: sha256:d1\n at: \"2026-09-30T00:00:00Z\"\n") + must(t, m.ScanStacks()) + return m +} + +func baseImages() *fakeImages { + return &fakeImages{ + imgs: []localImage{ + {ID: "sha256:W3", Repo: "acme/web", Tag: "3", Digest: "sha256:w3", Size: "100MB"}, + {ID: "sha256:W2", Repo: "acme/web", Tag: "2", Digest: "sha256:w2", Size: "100MB"}, + {ID: "sha256:W1", Repo: "acme/web", Tag: "1", Digest: "sha256:w1", Size: "100MB"}, + {ID: "sha256:P18", Repo: "postgres", Tag: "18-alpine", Digest: "sha256:p18", Size: "300MB"}, + {ID: "sha256:P16", Repo: "postgres", Tag: "16-alpine", Digest: "sha256:p16", Size: "290MB"}, + {ID: "sha256:D1", Repo: "acme/docs", Tag: "1", Digest: "sha256:d1", Size: "50MB"}, + {ID: "sha256:CTL", Repo: "gitea.dooplex.hu/admin/felhom-controller", Tag: "0.283.0", Digest: "", Size: "400MB"}, + }, + containers: map[string]string{"c-web": "sha256:W3", "c-webdb": "sha256:P18", "c-docs": "sha256:D1", "c-docsdb": "sha256:P18"}, + } +} + +// After an update: the running image and the one before it stay; the older one goes; the shared engine stays. +// COMPANION RED-PROOF: drop previous_images from imageKeepSet → "the undo's image (web:2) was deleted". +func TestImageRetention_KeepsRunningAndPreviousDeletesOlder(t *testing.T) { + m := retentionManager(t) + f := baseImages() + withFakeImages(t, f) + st, _ := m.GetStack("web") + if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig), ""); err != nil { + t.Fatal(err) + } + got := strings.Join(f.rmi, ",") + if strings.Contains(got, "sha256:W2") { + t.Fatal("the undo's image (web:2) was deleted") + } + if strings.Contains(got, "sha256:W3") || strings.Contains(got, "sha256:P18") { + t.Fatalf("a running image was deleted: %s", got) + } + if !strings.Contains(got, "sha256:W1") { + t.Fatalf("the older web:1 was not deleted: %s", got) + } + if !strings.Contains(got, "sha256:P16") { + t.Fatalf("postgres:16-alpine is this app's repo and nothing keeps it — expected deleted: %s", got) + } + if strings.Contains(got, "sha256:CTL") || strings.Contains(got, "sha256:D1") { + t.Fatalf("another app's or the controller's image was touched: %s", got) + } +} + +// A shared image survives the remove of one of its apps (another app's container and compose name it). +// COMPANION RED-PROOF: drop the container half of the keep set (return an empty map from imagesUsedByContainers) +// AND the compose half → "the shared postgres:18-alpine was deleted". +func TestImageRetention_ASharedImageSurvivesTheRemoveOfOneApp(t *testing.T) { + m := retentionManager(t) + f := baseImages() + withFakeImages(t, f) + st, _ := m.GetStack("web") + repos := appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig) + // the remove took web's containers away + delete(f.containers, "c-web") + delete(f.containers, "c-webdb") + m.mu.Lock() + m.stacks["web"].Deployed = false + m.mu.Unlock() + m.RetainImagesAfterRemove("web", repos) + got := strings.Join(f.rmi, ",") + if strings.Contains(got, "sha256:P18") { + t.Fatal("the shared postgres:18-alpine was deleted while docs still runs it") + } + for _, id := range []string{"sha256:W3", "sha256:W2", "sha256:W1"} { + if !strings.Contains(got, id) { + t.Fatalf("the removed app's image %s was kept: %s", id, got) + } + } +} + +// The keep set is checked from the compose too, not only containers: an installed app whose containers are down +// (stopped by the household, or mid-restart) keeps its images. +// COMPANION RED-PROOF: drop the ParseComposeImages loop from imageKeepSet → docs' image goes. +func TestImageRetention_AStoppedAppsComposeKeepsItsImage(t *testing.T) { + m := retentionManager(t) + f := baseImages() + f.containers = map[string]string{} // nothing running anywhere + withFakeImages(t, f) + m.RunImageRetentionOnce() // catalog-cache is absent → skipped, no marker + if len(f.rmi) != 0 { + t.Fatalf("the one-time sweep ran without a catalog: %v", f.rmi) + } + st, _ := m.GetStack("docs") + m.mu.Lock() + m.stacks["docs"].AppConfig.InstalledImages = nil // only the compose names it now + m.mu.Unlock() + if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), nil), ""); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(f.rmi, ","), "sha256:D1") { + t.Fatal("a stopped app's image was deleted although its compose names it") + } +} + +// A keep set that cannot be read deletes NOTHING (fail closed). +func TestImageRetention_UnreadableKeepSetDeletesNothing(t *testing.T) { + m := retentionManager(t) + f := baseImages() + withFakeImages(t, f) + prev := imageDocker + imageDocker = func(args ...string) (string, error) { + if args[0] == "ps" { + return "", fmt.Errorf("daemon hiccup") + } + return f.run(args...) + } + t.Cleanup(func() { imageDocker = prev }) + if _, err := m.deleteUnkeptImages("test", map[string]bool{"acme/web": true, "postgres": true}, ""); err == nil { + t.Fatal("no error from an unreadable keep set") + } + if len(f.rmi) != 0 { + t.Fatalf("deleted with no keep set: %v", f.rmi) + } +} + +// The one-time sweep reaches only images the catalog names (app images) — never the controller's. +func TestImageRetention_OneTimeSweepOnlyCatalogRepos(t *testing.T) { + m := retentionManager(t) + f := baseImages() + f.imgs = append(f.imgs, localImage{ID: "sha256:OLD", Repo: "acme/gone", Tag: "5", Digest: "sha256:g5", Size: "70MB"}) + withFakeImages(t, f) + cat := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates", "gone") + must(t, os.MkdirAll(cat, 0o755)) + must(t, os.WriteFile(filepath.Join(cat, "docker-compose.yml"), []byte("services:\n gone:\n image: acme/gone:6\n"), 0o644)) + deleted := m.RunImageRetentionOnce() + got := strings.Join(f.rmi, ",") + if !strings.Contains(got, "sha256:OLD") { + t.Fatalf("an earlier-removed app's image was not swept: %v", deleted) + } + if strings.Contains(got, "sha256:CTL") || strings.Contains(got, "sha256:W1") { + t.Fatalf("the sweep reached beyond the catalog's repos: %s", got) + } + if _, err := os.Stat(m.imageRetentionMarker()); err != nil { + t.Fatal("no marker — the sweep would run at every start") + } + f.rmi = nil + m.RunImageRetentionOnce() + if len(f.rmi) != 0 { + t.Fatal("the one-time sweep ran twice") + } +} + +// An update in flight pauses every pass: its undo's image is named by nothing the keep set reads. +// COMPANION RED-PROOF: drop the busy check in deleteUnkeptImages → "a pass ran while docs was updating". +func TestImageRetention_NoPassWhileAnUpdateRuns(t *testing.T) { + m := retentionManager(t) + f := baseImages() + withFakeImages(t, f) + m.mu.Lock() + m.stacks["docs"].Updating = true + m.mu.Unlock() + st, _ := m.GetStack("web") + if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig), ""); err != nil { + t.Fatal(err) + } + if len(f.rmi) != 0 { + t.Fatalf("a pass ran while docs was updating: %v", f.rmi) + } +} diff --git a/controller/internal/stacks/install_hold.go b/controller/internal/stacks/install_hold.go new file mode 100644 index 0000000..27bdd87 --- /dev/null +++ b/controller/internal/stacks/install_hold.go @@ -0,0 +1,211 @@ +package stacks + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// ── The install hold (R-741, `09` §3 decision 45) ───────────────────────────────────────────────────── +// +// Measured 2026-09-30 on 9202 (calibre-web): an app whose template carries `after_install:` answered its PUBLIC +// default login through traefik for 1–18 s — the app was published at its first start, and the box replaced the +// login only after the app was up. So such an app is installed HELD: before its first start a traefik file puts +// the setup gate's door (forwardAuth, internal/web/setup_gate.go) in front of every router it publishes. A +// stranger is refused; the household (a dashboard session) still passes — so a failed after_install leaves the +// household able to change the login by hand and say so ("I changed it"). The hold OPENS when after_install +// succeeds (runAfterInstallNow) or when the household says it changed the login (MarkDefaultLoginChanged); opening +// removes the file. The record (`install_hold:` in app.yaml, the gate's record shape) is reconciled by the gate's +// loop: a closed hold keeps its file; a hold whose after_install already succeeded, or whose login the household +// changed, opens; an absent after_install record (a controller restart cut the hook off) is run again once per +// process. Its priority beats the setup gate and the sign-up block, so a gated app is held first. +// Pinned by internal/stacks/install_hold_test.go. + +const ( + InstallHoldByAfterInstall = "after_install" + InstallHoldByHousehold = "household" +) + +func (m *Manager) installHoldPath(name string) string { + return filepath.Join(m.setupGateDir(), "install-hold-"+name+".yml") +} + +// renderInstallHold is the traefik file: every router the app publishes, same rule, a priority above the gate's +// and the sign-up block's, the gate's forwardAuth door, then the app's own docker service. +func renderInstallHold(name string, rs []gateRouter) string { + var b strings.Builder + mw := "felhom-install-hold-" + name + fmt.Fprintf(&b, "# Install hold for %s — managed by felhom-controller (R-741, `09` §3 decision 45).\n", name) + b.WriteString("# Only the household reaches the app until its known first login has been replaced; then this file is removed.\n") + b.WriteString("http:\n middlewares:\n") + fmt.Fprintf(&b, " %s:\n forwardAuth:\n address: %q\n", mw, setupGateAuthURL) + b.WriteString(" routers:\n") + for _, r := range rs { + fmt.Fprintf(&b, " %s-%s:\n", mw, r.Name) + fmt.Fprintf(&b, " rule: %q\n", r.Rule) + fmt.Fprintf(&b, " priority: %d\n", 3*setupGatePriority+len(r.Rule)) + b.WriteString(" entryPoints:\n - websecure\n") + if r.CertResolver != "" { + fmt.Fprintf(&b, " tls:\n certResolver: %s\n", r.CertResolver) + } else { + b.WriteString(" tls: {}\n") + } + fmt.Fprintf(&b, " middlewares:\n - %s@file\n", mw) + fmt.Fprintf(&b, " service: %q\n", r.Service+"@docker") + } + return b.String() +} + +func (m *Manager) writeInstallHold(name, composePath string, env map[string]string) ([]string, error) { + rs, err := gateRoutersFromCompose(composePath, env) + if err != nil { + return nil, err + } + if err := os.MkdirAll(m.setupGateDir(), 0o755); err != nil { + return nil, err + } + want := renderInstallHold(name, rs) + p := m.installHoldPath(name) + if cur, err := os.ReadFile(p); err == nil && string(cur) == want { + return gateHosts(rs), nil + } + tmp := p + ".tmp" + if err := os.WriteFile(tmp, []byte(want), 0o644); err != nil { + return nil, err + } + if err := os.Rename(tmp, p); err != nil { + return nil, err + } + return gateHosts(rs), nil +} + +func (m *Manager) removeInstallHoldFile(name string) error { + err := os.Remove(m.installHoldPath(name)) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// wantsInstallHold: the template replaces a known first login after the install. +func wantsInstallHold(meta *Metadata) bool { + ai := meta.AfterInstall + return ai != nil && ai.Service != "" && len(ai.Command) > 0 && ai.Success != "" +} + +// prepareInstallHold is DeployStack's step for an after_install template on a FRESH install: the file first (it +// must stand before the first start), then the record the caller saves with the app. +func (m *Manager) prepareInstallHold(name, composePath string, env map[string]string) (*SetupGateRecord, error) { + hosts, err := m.writeInstallHold(name, composePath, env) + if err != nil { + return nil, err + } + m.logger.Printf("[INFO] [stacks] %s: install HOLD before the first start — only the household reaches %v until the known first login is replaced", name, hosts) + return &SetupGateRecord{State: SetupGateClosed, Since: m.now().UTC().Format(time.RFC3339), Hosts: hosts}, nil +} + +// OpenInstallHold opens an app's hold: the record first, then the file (a failed removal is retried by the loop). +// A hold that is not closed is not an error — after_install succeeding on an app never held (installed before +// this release) opens nothing. +func (m *Manager) OpenInstallHold(name, by string) error { + st, ok := m.GetStack(name) + if !ok || st.AppConfig == nil || !st.AppConfig.InstallHold.Closed() { + return nil + } + dir := filepath.Dir(st.ComposePath) + now := m.now().UTC().Format(time.RFC3339) + opened := false + m.mutateAppConfig(name, dir, "install_hold", func(cfg *AppConfig) bool { + if !cfg.InstallHold.Closed() { + return false + } + cfg.InstallHold.State, cfg.InstallHold.OpenedAt, cfg.InstallHold.OpenedBy = SetupGateOpen, now, by + opened = true + return true + }) + if !opened { + return fmt.Errorf("install hold %s: the record could not be written", name) + } + if err := m.removeInstallHoldFile(name); err != nil { + m.logger.Printf("[ERROR] [stacks] %s: install hold opened but its traefik file could not be removed (%v) — the loop retries", name, err) + } + m.logger.Printf("[INFO] [stacks] %s: install hold OPENED by %s — the app is reached as without a hold", name, by) + return nil +} + +// installHoldProcessStart: only an install made BEFORE this process started can have lost its hook (the hook runs +// after_install in this process's own goroutine right after an install made now). +var installHoldProcessStart = time.Now() + +// installHoldRetried: apps whose absent after_install record this process already re-ran (once per process). +var installHoldRetried sync.Map + +// installHoldAfterInstall is RunAfterInstall, a seam for the tests. +var installHoldAfterInstall = func(m *Manager, name string) { _, _ = m.RunAfterInstall(name, 10*time.Minute) } + +// installHoldTick is the hold's part of SetupGateTick: stale files go, closed holds keep their file, and a hold +// whose login is already replaced opens. +func (m *Manager) installHoldTick() { + type item struct { + name, dir, compose string + opened, rerun string + } + var items []item + keep := map[string]bool{} + m.mu.RLock() + for n, st := range m.stacks { + if !st.Deployed || st.AppConfig == nil || !st.AppConfig.InstallHold.Closed() { + continue + } + it := item{name: n, dir: filepath.Dir(st.ComposePath), compose: st.ComposePath} + switch { + case st.AppConfig.AfterInstall != nil && st.AppConfig.AfterInstall.OK: + it.opened = InstallHoldByAfterInstall + case st.AppConfig.DefaultLogin != nil: + it.opened = InstallHoldByHousehold + case st.AppConfig.AfterInstall == nil && (st.State == StateRunning || st.State == StateUnhealthy) && !st.Deploying: + if at, err := time.Parse(time.RFC3339, st.AppConfig.DeployedAt); err == nil && at.Before(installHoldProcessStart.Truncate(time.Second)) { // DeployedAt has whole seconds + it.rerun = "yes" + } + } + items = append(items, it) + keep[n] = true + } + m.mu.RUnlock() + if ents, err := os.ReadDir(m.setupGateDir()); err == nil { + for _, e := range ents { + n := e.Name() + if !strings.HasPrefix(n, "install-hold-") || !strings.HasSuffix(n, ".yml") { + continue + } + app := strings.TrimSuffix(strings.TrimPrefix(n, "install-hold-"), ".yml") + if !keep[app] { + if err := m.removeInstallHoldFile(app); err == nil { + m.logger.Printf("[INFO] [stacks] %s: removed an install-hold file for an app that is not held", app) + } + } + } + } + for _, it := range items { + if it.opened != "" { + if err := m.OpenInstallHold(it.name, it.opened); err != nil { + m.logger.Printf("[ERROR] [stacks] %s: %v", it.name, err) + } + continue + } + if cfg := LoadAppConfigDecrypted(it.dir, m.encKey); cfg != nil { + if _, err := m.writeInstallHold(it.name, it.compose, cfg.Env); err != nil { + m.logger.Printf("[ERROR] [stacks] %s: the install hold's traefik file could not be (re)written: %v", it.name, err) + } + } + if it.rerun != "" { + if _, done := installHoldRetried.LoadOrStore(it.name, true); !done { + m.logger.Printf("[WARN] [stacks] %s: held, and its after_install never ran (a restart cut the install hook off) — running it now", it.name) + go installHoldAfterInstall(m, it.name) + } + } + } +} diff --git a/controller/internal/stacks/install_hold_test.go b/controller/internal/stacks/install_hold_test.go new file mode 100644 index 0000000..00840a2 --- /dev/null +++ b/controller/internal/stacks/install_hold_test.go @@ -0,0 +1,198 @@ +package stacks + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// R-741 (decision 45): an after_install app is installed HELD — the gate's door in front of it — until its +// known first login is replaced. Nothing here reaches Docker (gateManager's stub + the composeExecFn/afterLoadFn seams). + +const heldYml = "display_name: Held App\n" + + "after_install:\n service: gapp\n env: [ADMIN_PASSWORD]\n command: [\"set-pw\", \"admin:${ADMIN_PASSWORD}\"]\n success: \"changed\"\n" + + "app_info:\n default_creds: \"admin / admin123\"\n" + + "deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n" + + " - env_var: ADMIN_PASSWORD\n type: password\n generate: \"password:24\"\n" + +func deployHeld(t *testing.T, m *Manager) (existedAtUp bool, atUp string) { + t.Helper() + p := m.installHoldPath("gapp") + m.composeExecFn = func(_ string, _ map[string]string, args ...string) (string, error) { + if len(args) > 0 && args[0] == "up" { + b, err := os.ReadFile(p) + existedAtUp, atUp = err == nil, string(b) + } + return "", nil + } + done := make(chan bool, 1) + m.SetDeployDoneHook(func(_ string, ok bool, _ string) { done <- ok }) + if _, err := m.DeployStack(DeployRequest{StackName: "gapp", Values: map[string]string{"ADMIN_PASSWORD": "Gen-Pw-123456789"}}); err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(20 * time.Second): + t.Fatal("the deploy never ended") + } + return existedAtUp, atUp +} + +// The hold stands BEFORE the first start, and it is the gate's door (forwardAuth), above the gate's priority. +// COMPANION RED-PROOF: drop the prepareInstallHold block in DeployStack → "the hold file did not exist" fails. +func TestInstallHold_WrittenBeforeTheFirstStart(t *testing.T) { + m := gateManager(t, heldYml) + existed, atUp := deployHeld(t, m) + if !existed { + t.Fatal("the hold file did not exist when the app was first started — its known default login was reachable (R-741)") + } + for _, want := range []string{"Host(`gapp.example.hu`)", `service: "gapp@docker"`, setupGateAuthURL, "felhom-install-hold-gapp@file"} { + if !strings.Contains(atUp, want) { + t.Errorf("the hold file lacks %q:\n%s", want, atUp) + } + } + if !strings.Contains(atUp, "priority: 3000") { + t.Errorf("the hold must outrank the setup gate and the sign-up block:\n%s", atUp) + } + cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp")) + if cfg == nil || !cfg.InstallHold.Closed() || strings.Join(cfg.InstallHold.Hosts, ",") != "gapp.example.hu" { + t.Fatalf("app.yaml hold record: %+v", cfg) + } + if app, closed, found := m.SetupGateHost("gapp.example.hu"); !found || !closed || app != "gapp" { + t.Fatalf("the door must see the held host as closed: %q %v %v", app, closed, found) + } +} + +// A template without after_install is never held (no change for 40-odd apps). +func TestInstallHold_OnlyForAfterInstallTemplates(t *testing.T) { + m := gateManager(t, "display_name: Plain\ndeploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n") + existed, _ := deployHeld(t, m) + if existed { + t.Fatal("an app without after_install was held") + } +} + +// after_install succeeding OPENS the hold: record first, file gone, the door lets everyone through. +// COMPANION RED-PROOF: drop the OpenInstallHold call in runAfterInstallNow → "still held after the login was replaced". +func TestInstallHold_OpensWhenAfterInstallSucceeds(t *testing.T) { + m := gateManager(t, heldYml) + deployHeld(t, m) + m.afterLoadFn = func(string, ...string) (string, error) { return "Password for user 'admin' changed", nil } + st, _ := m.GetStack("gapp") + dir := filepath.Dir(st.ComposePath) + rec := func(ok bool, d string) { + m.mutateAppConfig("gapp", dir, "after_install", func(c *AppConfig) bool { + c.AfterInstall = &AfterInstallRecord{At: "x", OK: ok, Detail: d} + return true + }) + } + if err := m.runAfterInstallNow("gapp", st.Meta.AfterInstall, []string{"set-pw", "admin:x"}, rec); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(m.installHoldPath("gapp")); !os.IsNotExist(err) { + t.Fatal("the hold file is still there after the login was replaced") + } + cfg := LoadAppConfig(dir) + if cfg.InstallHold.Closed() || cfg.InstallHold.OpenedBy != InstallHoldByAfterInstall { + t.Fatalf("still held after the login was replaced: %+v", cfg.InstallHold) + } + if _, closed, _ := m.SetupGateHost("gapp.example.hu"); closed { + t.Fatal("the door still refuses strangers after the hold opened") + } +} + +// A failed after_install keeps the hold (the app is NOT published with its known login); the household's +// "I changed it" opens it. +// COMPANION RED-PROOF: drop the OpenInstallHold call in MarkDefaultLoginChanged → "the household's word did not open". +func TestInstallHold_FailureKeepsItTheHouseholdOpensIt(t *testing.T) { + m := gateManager(t, heldYml) + deployHeld(t, m) + st, _ := m.GetStack("gapp") + dir := filepath.Dir(st.ComposePath) + m.mutateAppConfig("gapp", dir, "after_install", func(c *AppConfig) bool { + c.AfterInstall = &AfterInstallRecord{At: "x", OK: false, Detail: "no marker"} + return true + }) + must(t, m.ScanStacks()) + m.installHoldTick() + if _, err := os.Stat(m.installHoldPath("gapp")); err != nil { + t.Fatal("a failed after_install dropped the hold — the known default login would be public") + } + must(t, m.ScanStacks()) + if err := m.MarkDefaultLoginChanged("gapp", "household"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(m.installHoldPath("gapp")); !os.IsNotExist(err) { + t.Fatal("the household's word did not open the hold") + } +} + +// The loop: a record that says the login was replaced (a restore, a crash between record and removal) opens; a +// stale file of an app that is not held goes; a closed hold's file is (re)written. +func TestInstallHold_LoopReconciles(t *testing.T) { + m := gateManager(t, heldYml) + deployHeld(t, m) + st, _ := m.GetStack("gapp") + dir := filepath.Dir(st.ComposePath) + must(t, os.Remove(m.installHoldPath("gapp"))) + must(t, m.ScanStacks()) + m.installHoldTick() + if _, err := os.Stat(m.installHoldPath("gapp")); err != nil { + t.Fatal("a closed hold's missing file was not rewritten") + } + must(t, os.WriteFile(m.installHoldPath("ghost"), []byte("x"), 0o644)) + m.mutateAppConfig("gapp", dir, "after_install", func(c *AppConfig) bool { + c.AfterInstall = &AfterInstallRecord{At: "x", OK: true} + return true + }) + must(t, m.ScanStacks()) + m.installHoldTick() + if _, err := os.Stat(m.installHoldPath("gapp")); !os.IsNotExist(err) { + t.Fatal("a hold whose after_install succeeded was not opened by the loop") + } + if _, err := os.Stat(m.installHoldPath("ghost")); !os.IsNotExist(err) { + t.Fatal("a stale hold file of an app that is not held was kept") + } +} + +// An install made by THIS process is never re-run by the loop (its hook is running after_install already); one +// made before the process started, with no record, is re-run once. +// COMPANION RED-PROOF: drop the DeployedAt-before-process-start check → "re-ran an install this process made". +func TestInstallHold_ReRunsOnlyAnInstallTheRestartCutOff(t *testing.T) { + m := gateManager(t, heldYml) + deployHeld(t, m) + calls := 0 + prev := installHoldAfterInstall + installHoldAfterInstall = func(*Manager, string) { calls++ } + defer func() { installHoldAfterInstall = prev }() + installHoldRetried.Delete("gapp") + defer installHoldRetried.Delete("gapp") + setRunning := func() { + m.mu.Lock() + m.stacks["gapp"].State = StateRunning + m.stacks["gapp"].Deploying = false + m.mu.Unlock() + } + must(t, m.ScanStacks()) + setRunning() + m.installHoldTick() + time.Sleep(20 * time.Millisecond) + if calls != 0 { + t.Fatal("the loop re-ran after_install for an install this process made — twice at once") + } + st, _ := m.GetStack("gapp") + m.mutateAppConfig("gapp", filepath.Dir(st.ComposePath), "deployed_at", func(c *AppConfig) bool { + c.DeployedAt = installHoldProcessStart.Add(-time.Hour).UTC().Format(time.RFC3339) + return true + }) + must(t, m.ScanStacks()) + setRunning() + m.installHoldTick() + m.installHoldTick() + time.Sleep(20 * time.Millisecond) + if calls != 1 { + t.Fatalf("an install the restart cut off was re-run %d times, want once", calls) + } +} diff --git a/controller/internal/stacks/life_records.go b/controller/internal/stacks/life_records.go index 1c3cf01..a872d1c 100644 --- a/controller/internal/stacks/life_records.go +++ b/controller/internal/stacks/life_records.go @@ -36,6 +36,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) { // opened; a gate that was still closed stays closed (its probe opens it if the restored data is set up). // No prior record (a removed app, kept data, a rebuilt guest) = no gate: the data comes back with its admin. cfg.SetupGate = prior.SetupGate + cfg.InstallHold = prior.InstallHold // R-741: the loop opens it when the restored record says the login was replaced cfg.DefaultLogin = prior.DefaultLogin cfg.AfterSetup = prior.AfterSetup if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 { diff --git a/controller/internal/stacks/setup_gate.go b/controller/internal/stacks/setup_gate.go index 96b183d..59f1707 100644 --- a/controller/internal/stacks/setup_gate.go +++ b/controller/internal/stacks/setup_gate.go @@ -345,6 +345,16 @@ func (m *Manager) SetupGateHost(host string) (name string, closed bool, found bo host = strings.ToLower(host) m.mu.RLock() defer m.mu.RUnlock() + // R-741: a closed install hold answers first (its routers outrank the gate's); an app with both is closed while + // either is. + for n, st := range m.stacks { + if st.AppConfig == nil || !st.AppConfig.InstallHold.Closed() { + continue + } + if containsStr(st.AppConfig.InstallHold.Hosts, host) { + return n, true, true + } + } for n, st := range m.stacks { if st.AppConfig == nil || st.AppConfig.SetupGate == nil { continue @@ -486,6 +496,7 @@ func (m *Manager) SetupGateTick() { } } m.reconcileSignupBlocks() + m.installHoldTick() } // RunSetupGateLoop runs SetupGateTick every interval until ctx ends. @@ -521,5 +532,8 @@ func (m *Manager) MarkDefaultLoginChanged(name, by string) error { return fmt.Errorf("%s: app.yaml could not be read", name) } m.logger.Printf("[INFO] [stacks] %s: the household says it changed the default login by hand — the page stops naming it", name) + if err := m.OpenInstallHold(name, InstallHoldByHousehold); err != nil { // R-741 + m.logger.Printf("[ERROR] [stacks] %s: %v — the loop retries", name, err) + } return nil } diff --git a/controller/internal/stacks/undo.go b/controller/internal/stacks/undo.go index 5d40af6..3a5f21f 100644 --- a/controller/internal/stacks/undo.go +++ b/controller/internal/stacks/undo.go @@ -564,6 +564,7 @@ func (m *Manager) tryUndo(ctx context.Context, name, dir, why string, entry *upd m.logger.Printf("[WARN] [stacks] update %s: rescan after the undo failed: %v", name, err) } m.finishUpdate(name, UpdatePhaseUndone, "") + m.retainAfterUpdate(name, nil) // decision 53: the app runs its old image again; the attempt's is not kept m.emitUpdateEvent(UpdateEventUndone, name, entry, UpdateRestorePoint{}, true) m.logger.Printf("[INFO] [stacks] update %s: UNDONE in %s — the previous version is running on the data from before the update (%s)", name, m.now().Sub(start).Round(time.Second), detail) return "" diff --git a/controller/internal/stacks/update.go b/controller/internal/stacks/update.go index 8df5165..e505feb 100644 --- a/controller/internal/stacks/update.go +++ b/controller/internal/stacks/update.go @@ -757,6 +757,13 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) { dir := filepath.Dir(st.ComposePath) g := m.guards() entry := updateJournalEntry{StartedAt: start} + // decision 53: what the app ran before this press — kept as the previous image if the update ends done. + if st.AppConfig != nil && len(st.AppConfig.InstalledImages) > 0 { + entry.BeforeImages = make(map[string]InstalledImage, len(st.AppConfig.InstalledImages)) + for k, v := range st.AppConfig.InstalledImages { + entry.BeforeImages[k] = v + } + } fail := func(key, detail string, args ...interface{}) { m.logger.Printf("[ERROR] [stacks] update %s FAILED in phase %s after %s — nothing was moved: %s", name, entry.Phase, m.now().Sub(start).Round(time.Millisecond), detail) m.clearJournal(name) @@ -1031,6 +1038,7 @@ func (m *Manager) verifyAndConclude(ctx context.Context, name, dir string, env [ } m.finishUpdate(name, UpdatePhaseDone, "") m.logger.Printf("[INFO] [stacks] update %s: DONE in %s", name, m.now().Sub(start).Round(time.Second)) + m.retainAfterUpdate(name, entry.BeforeImages) // decision 53 (R-736) } // holdLogTailLines is how much of each service's log the hold keeps. 400 lines is enough to hold a @@ -1259,12 +1267,14 @@ func (m *Manager) waitUpdateHealthyMeta(ctx context.Context, name string, timeou // ── the journal ────────────────────────────────────────────────────────────────────────────────── type updateJournalEntry struct { - Phase string `json:"phase"` - StartedAt time.Time `json:"started_at"` - PrevPin map[string]string `json:"prev_pin,omitempty"` - PrevCompose string `json:"prev_compose,omitempty"` - PrevApplied string `json:"prev_applied,omitempty"` - ProvenCopyAt string `json:"proven_copy_at,omitempty"` + Phase string `json:"phase"` + StartedAt time.Time `json:"started_at"` + PrevPin map[string]string `json:"prev_pin,omitempty"` + // BeforeImages (decision 53): installed_images at the press — the previous image kept if the update ends done. + BeforeImages map[string]InstalledImage `json:"before_images,omitempty"` + PrevCompose string `json:"prev_compose,omitempty"` + PrevApplied string `json:"prev_applied,omitempty"` + ProvenCopyAt string `json:"proven_copy_at,omitempty"` // ProvenTier (R-475) — which tier ProvenCopyAt belongs to, so a resumed update that fails names // the right copy. 0 in a journal written by v0.238.1 or older. ProvenTier int `json:"proven_tier,omitempty"` diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index c4f973f..f3c05b2 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -775,6 +775,8 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s // v0.280.0 (decision 46): the setup gate's card, while the gate stands. data["SetupGateClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.SetupGate.Closed() data["SetupGateHasProbe"] = found.Meta.SetupDoneProbe != nil && found.Meta.SetupDoneProbe.URL != "" + // R-741: the install hold's card, while the app is held for its first-login change. + data["InstallHoldClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.InstallHold.Closed() // v0.282.0 (decision 49): "close sign-up now" on an app installed before the rule; the app's own switch. if s.stackMgr != nil { data["CloseSignupOffered"] = s.stackMgr.CloseSignupOffered(found.Name) diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index ee36607..2b24899 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -329,6 +329,17 @@ func i18nCases() []i18nCase { }} } base = append(base, gateCase("app_info_setup_gate_button", false), gateCase("app_info_setup_gate_probe", true)) + // R-741: the install hold's card, while an after_install app waits for its first-login change. + base = append(base, i18nCase{"app_info_install_hold", "app_info", func() map[string]interface{} { + d := i18nLayoutData("stacks", "Calibre") + st := stacks.Stack{Name: "calibre-web", Deployed: true, State: stacks.StateRunning} + st.Meta = stacks.Metadata{DisplayName: "Calibre", Slug: "calibre-web"} + d["Stack"] = st + d["Meta"] = st.Meta + d["AppInfo"] = st.Meta.AppInfo + d["InstallHoldClosed"] = true + return d + }}) // v0.281.0 (decision 47): the sign-up card, closed (with the app's how-to) and open for the household's window. signupCase := func(name string, closed bool, until string) i18nCase { return i18nCase{name, "app_info", func() map[string]interface{} { diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 7a5cb2e..89d4830 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -85,6 +85,12 @@ onerror="this.style.display='none'"> +{{- if .InstallHoldClosed}} +
+

{{T "app_info.install_hold_title"}}

+

{{T "app_info.install_hold_closed"}}

+
+{{- end}} {{- if .SetupGateClosed}}

{{T "app_info.setup_gate_title"}}

diff --git a/controller/internal/web/testdata/i18n_parity/app_info_install_hold.html b/controller/internal/web/testdata/i18n_parity/app_info_install_hold.html new file mode 100644 index 0000000..9f7f2ed --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_info_install_hold.html @@ -0,0 +1,570 @@ + + + + + + + + Calibre — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
+ +
+ +

+ +
+ ~ RAM + + + Csak x86 + +
+ +
+
+ + +
+ + + +
+
+

Még csak te éred el

+

A doboz most cseréli le az alkalmazás ismert első jelszavát egy saját, generált jelszóra. Addig csak te éred el, amíg be vagy jelentkezve a vezérlőpultba – más nem léphet be az ismert jelszóval. Ha a csere nem sikerül, változtasd meg a jelszót kézzel, és jelezd itt, hogy megtetted.

+
+ + + + + + + + + + +
+ + + +