v0.284.0 — a box deletes old app images (decision 53, R-736); an after_install app is held until its known login is replaced (R-741)
gates / gates (push) Successful in 27s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-30 22:29:33 +02:00
parent d48da6c7f3
commit 5a3437669f
23 changed files with 1724 additions and 23 deletions
+30
View File
@@ -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) ## 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. **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). No new strings.
+6 -1
View File
@@ -7,7 +7,12 @@
> >
> Ask Claude Code: "Please update CONTEXT.md with what we did today" > 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 > **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 > (an earlier per-app choice wins); one press for older apps; `backup/offbox_fit.go` size card (estimate at each run
+9 -16
View File
@@ -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: - **Image retention (`09` §3 decision 53, R-736):** after a done/undone guarded Update and at remove, an app's older
`felhom.eu/documentation/audits/evidence-fixes-first-tester-2026-09-30/`. 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.
- **v0.283.0 — decision 50 (R-720):** a fresh install joins the off-site copy when the customer has off-site; one - **Install hold (R-741):** an `after_install` app is held behind the setup gate's door from its first start until the
press for older apps on both backup pages; the size card over the quota. Measured first: over the quota nothing known login is replaced (after_install, or the household's "I changed it").
but the ruled retention runs — pinned by `TestDecision50_OverQuotaDeletesNothingExtra`. - Tests: `TestImageRetention_*` (6), `TestInstallHold_*` (6), parity fixture `app_info_install_hold`; red-proofs in
- **R-721:** a Stop pressed while a machine has the app down holds — quiesce resume, volume dump, update leg (v0.283.0), `felhom.eu/documentation/audits/night-rulings-2026-09-30/{B,C}/`. Green gate: build, vet, test ./... rc=0.
the dump's PRODUCTION adapter and the startup crash recovery (v0.283.1, after the live test on 9202 caught v0.283.0 - Evidence and the live proofs: `felhom.eu/documentation/audits/night-rulings-2026-09-30/`.
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).
+2
View File
@@ -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.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.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.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`) | | `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 | | `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` | | `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` |
+8
View File
@@ -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 **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/<slug>/default-login/changed` before its template gained the command is warned), and the card has "I changed it" (`POST /apps/<slug>/default-login/changed`
→ `app.yaml` `default_login`), after which the card goes. → `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-<app>.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, - **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 done}`. A FRESH install is closed to everyone but the household: the traefik file
`<stacks>/traefik/dynamic/setup-gate-<app>.yml` is written BEFORE the first start (a failed write refuses the install) and `<stacks>/traefik/dynamic/setup-gate-<app>.yml` is written BEFORE the first start (a failed write refuses the install) and
+10
View File
@@ -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 // 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. // that says "set up" opens its gate, and a gate file nobody owns is removed.
go stackMgr.RunSetupGateLoop(ctx, 20*time.Second) 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 --- // --- Initialize API router ---
apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, updater, notifier, logger) apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, updater, notifier, logger)
+2
View File
@@ -2473,6 +2473,8 @@
"setup_gate.sign_in": "Sign in", "setup_gate.sign_in": "Sign in",
"app_info.setup_gate_title": "First setup", "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.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_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_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", "app_info.setup_gate_done_btn": "Done, I set it up",
+2
View File
@@ -2461,6 +2461,8 @@
"setup_gate.sign_in": "Bejelentkezés", "setup_gate.sign_in": "Bejelentkezés",
"app_info.setup_gate_title": "Első beállítá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.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_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_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", "app_info.setup_gate_done_btn": "Kész, beállítottam",
@@ -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", 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) name, ai.Service, ai.Command, time.Since(t0).Round(time.Millisecond), try)
record(true, "") record(true, "")
if err := m.OpenInstallHold(name, InstallHoldByAfterInstall); err != nil {
m.logger.Printf("[ERROR] [stacks] %s: %v — the loop retries", name, err)
}
return nil return nil
} }
last = fmt.Sprintf("try %d: err=%v, no %q in the output", try, err, ai.Success) last = fmt.Sprintf("try %d: err=%v, no %q in the output", try, err, ai.Success)
+4
View File
@@ -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 // Step 2: Run docker compose down --rmi local --volumes
// H14: Return error if docker compose down fails — continuing would leave orphaned containers. // H14: Return error if docker compose down fails — continuing would leave orphaned containers.
env := m.stackEnv(stackDir) env := m.stackEnv(stackDir)
@@ -364,6 +367,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
if err := m.ScanStacks(); err != nil { if err := m.ScanStacks(); err != nil {
m.logger.Printf("[WARN] Rescan after delete failed: %v", err) 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 return resp, nil
} }
+21
View File
@@ -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 // 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. // setup is done. A life record (carried across a restore). See setup_gate.go.
SetupGate *SetupGateRecord `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"` 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 // 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. // 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"` DefaultLogin *DefaultLoginRecord `yaml:"default_login,omitempty" json:"default_login,omitempty"`
@@ -434,6 +440,20 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
} }
gate = g 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. // Save app.yaml.
// CTRL-T2-1: persist the env now, but mark the ON-DISK state Deployed:false // 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. // reverts Deployed to false — so a failed deploy can never present as an app owed a restart.
DesiredState: DesiredStateRunning, DesiredState: DesiredStateRunning,
SetupGate: gate, SetupGate: gate,
InstallHold: hold,
} }
diskCfg := *appCfg diskCfg := *appCfg
@@ -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
}
@@ -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)
}
}
+211
View File
@@ -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)
}
}
}
}
@@ -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)
}
}
@@ -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). // 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. // 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.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.DefaultLogin = prior.DefaultLogin
cfg.AfterSetup = prior.AfterSetup cfg.AfterSetup = prior.AfterSetup
if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 { if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 {
+14
View File
@@ -345,6 +345,16 @@ func (m *Manager) SetupGateHost(host string) (name string, closed bool, found bo
host = strings.ToLower(host) host = strings.ToLower(host)
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() 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 { for n, st := range m.stacks {
if st.AppConfig == nil || st.AppConfig.SetupGate == nil { if st.AppConfig == nil || st.AppConfig.SetupGate == nil {
continue continue
@@ -486,6 +496,7 @@ func (m *Manager) SetupGateTick() {
} }
} }
m.reconcileSignupBlocks() m.reconcileSignupBlocks()
m.installHoldTick()
} }
// RunSetupGateLoop runs SetupGateTick every interval until ctx ends. // 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) 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) 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 return nil
} }
+1
View File
@@ -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.logger.Printf("[WARN] [stacks] update %s: rescan after the undo failed: %v", name, err)
} }
m.finishUpdate(name, UpdatePhaseUndone, "") 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.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) 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 "" return ""
+16 -6
View File
@@ -757,6 +757,13 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
dir := filepath.Dir(st.ComposePath) dir := filepath.Dir(st.ComposePath)
g := m.guards() g := m.guards()
entry := updateJournalEntry{StartedAt: start} 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{}) { 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.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) m.clearJournal(name)
@@ -1031,6 +1038,7 @@ func (m *Manager) verifyAndConclude(ctx context.Context, name, dir string, env [
} }
m.finishUpdate(name, UpdatePhaseDone, "") m.finishUpdate(name, UpdatePhaseDone, "")
m.logger.Printf("[INFO] [stacks] update %s: DONE in %s", name, m.now().Sub(start).Round(time.Second)) 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 // 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 ────────────────────────────────────────────────────────────────────────────────── // ── the journal ──────────────────────────────────────────────────────────────────────────────────
type updateJournalEntry struct { type updateJournalEntry struct {
Phase string `json:"phase"` Phase string `json:"phase"`
StartedAt time.Time `json:"started_at"` StartedAt time.Time `json:"started_at"`
PrevPin map[string]string `json:"prev_pin,omitempty"` PrevPin map[string]string `json:"prev_pin,omitempty"`
PrevCompose string `json:"prev_compose,omitempty"` // BeforeImages (decision 53): installed_images at the press — the previous image kept if the update ends done.
PrevApplied string `json:"prev_applied,omitempty"` BeforeImages map[string]InstalledImage `json:"before_images,omitempty"`
ProvenCopyAt string `json:"proven_copy_at,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 // 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. // the right copy. 0 in a journal written by v0.238.1 or older.
ProvenTier int `json:"proven_tier,omitempty"` ProvenTier int `json:"proven_tier,omitempty"`
+2
View File
@@ -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. // 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["SetupGateClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.SetupGate.Closed()
data["SetupGateHasProbe"] = found.Meta.SetupDoneProbe != nil && found.Meta.SetupDoneProbe.URL != "" 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. // 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 { if s.stackMgr != nil {
data["CloseSignupOffered"] = s.stackMgr.CloseSignupOffered(found.Name) data["CloseSignupOffered"] = s.stackMgr.CloseSignupOffered(found.Name)
@@ -329,6 +329,17 @@ func i18nCases() []i18nCase {
}} }}
} }
base = append(base, gateCase("app_info_setup_gate_button", false), gateCase("app_info_setup_gate_probe", true)) 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. // 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 { signupCase := func(name string, closed bool, until string) i18nCase {
return i18nCase{name, "app_info", func() map[string]interface{} { return i18nCase{name, "app_info", func() map[string]interface{} {
@@ -85,6 +85,12 @@
onerror="this.style.display='none'"> onerror="this.style.display='none'">
</div> </div>
{{- if .InstallHoldClosed}}
<div class="app-info-card" style="margin-top:1rem" id="install-hold-card">
<h3>{{T "app_info.install_hold_title"}}</h3>
<p>{{T "app_info.install_hold_closed"}}</p>
</div>
{{- end}}
{{- if .SetupGateClosed}} {{- if .SetupGateClosed}}
<div class="app-info-card" style="margin-top:1rem" id="setup-gate-card"> <div class="app-info-card" style="margin-top:1rem" id="setup-gate-card">
<h3>{{T "app_info.setup_gate_title"}}</h3> <h3>{{T "app_info.setup_gate_title"}}</h3>
@@ -0,0 +1,570 @@
<!DOCTYPE html>
<html lang="hu" class="no-js">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calibre — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<meta name="csrf-token" content="tok">
<script>
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
function felhomConfirm(el,question,onYes){
if(!el||el.dataset.fcOpen)return;
el.dataset.fcOpen='1';
var wrap=document.createElement('span');wrap.className='inline-confirm';
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
no.addEventListener('click',close);
yes.addEventListener('click',function(){close();onYes();});
}
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
if(!btn)return;
e.preventDefault();
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
var form=btn.closest('form');
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
});
});
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
if(!btn)return;
var group=btn.closest('.nav-group');
if(!group)return;
var willOpen=!group.classList.contains('is-open');
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
g.classList.remove('is-open');
var t=g.querySelector('.nav-group-toggle');
if(t)t.setAttribute('aria-expanded','false');
});
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
});
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
</script>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
</svg>
<header class="mobile-topbar">
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
<svg class="ico"><use href="#i-menu"/></svg>
</button>
</header>
<div class="nav-backdrop" hidden></div>
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
</div>
<ul class="nav-links">
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
<li><a href="/stacks" class="active"><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>Tárhely<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/storage" class="">Meghajtók</a></li>
<li><a href="/storage/network" class="">Hálózati tárhely</a></li>
</ul>
</li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>Biztonsági mentés<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/backups" class="">Áttekintés</a></li>
<li><a href="/backups/remote" class="">Távoli mentés</a></li>
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
<li><a href="/backups/restore" class="">Visszaállítás</a></li>
</ul>
</li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>Megosztás<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/sharing" class="">Hálózati megosztás</a></li>
</ul>
</li>
<li><a href="/monitoring" class=""><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
<li><a href="/debug" class=""><svg class="ico"><use href="#i-wrench"/></svg>Debug</a></li>
</ul>
<div class="sidebar-bottom">
<div class="nav-group-label">Beállítások</div>
<ul class="nav-links nav-links-sub">
<li><a href="/settings" class=""><svg class="ico"><use href="#i-settings"/></svg>Rendszer</a></li>
<li><a href="/settings/notifications" class=""><svg class="ico"><use href="#i-bell"/></svg>Értesítések</a></li>
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
</nav>
<main class="content">
<div class="page-header">
<div style="display:flex;align-items:center;gap:1rem">
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
<h2>Calibre</h2>
</div>
<div style="display:flex;align-items:center;gap:.5rem">
<span class="stack-state-badge state-run">Fut</span>
<a href="/stacks/calibre-web/logs" class="btn btn-sm btn-outline">Napló</a>
<a href="/stacks/calibre-web/export" class="btn btn-sm btn-outline">Exportálás</a>
<a href="/stacks/calibre-web/deploy" class="btn btn-sm btn-outline">Beállítások</a>
</div>
</div>
<div class="app-info-hero">
<img class="app-info-logo" src="/static/assets/calibre-web-logo.svg"
alt="Calibre" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/calibre-web-logo.png';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
<div class="app-info-hero-text">
<p class="app-info-tagline"></p>
<div class="stack-meta-badges">
<span class="meta-badge">~ RAM</span>
<span class="meta-badge"></span>
<span class="meta-badge meta-badge-warn">Csak x86</span>
</div>
</div>
</div>
<div class="app-screenshots" id="screenshots">
<img src="/static/assets/calibre-web-screenshot-1.webp" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="/static/assets/calibre-web-screenshot-2.webp" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="/static/assets/calibre-web-screenshot-3.webp" alt="" class="app-screenshot"
onerror="this.style.display='none'">
</div>
<div class="app-info-card" style="margin-top:1rem" id="install-hold-card">
<h3>Még csak te éred el</h3>
<p>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.</p>
</div>
</main>
<script>
(function(){
var burger=document.querySelector('.nav-burger');
var sidebar=document.getElementById('sidebar');
var backdrop=document.querySelector('.nav-backdrop');
if(!burger||!sidebar||!backdrop)return;
function setOpen(open){
sidebar.classList.toggle('is-open',open);
document.body.classList.toggle('nav-open',open);
backdrop.hidden=!open;
burger.setAttribute('aria-expanded',open?'true':'false');
}
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
backdrop.addEventListener('click',function(){setOpen(false);});
document.addEventListener('keydown',function(e){
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
});
})();
document.addEventListener('click', function(e) {
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
var card = e.target.closest('[data-href]');
if (card) window.location.href = card.dataset.href;
});
async function checkBeforeDeploy(e, name) {
try {
var resp = await fetch('/api/stacks/' + name);
var data = await resp.json();
if (data.ok && data.data && data.data.deployed) {
e.preventDefault();
showAlert('Ez az alkalmazás már telepítve van.');
window.location.reload();
return false;
}
} catch(err) {}
return true;
}
async function syncTemplates() {
const btn = document.getElementById('sync-btn');
const toast = document.getElementById('sync-toast');
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '↻ Frissítés...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/sync', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (toast) {
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
setTimeout(function() { window.location.reload(); }, 1500);
}
} catch (err) {
if (toast) {
toast.textContent = 'Hálózati hiba: ' + err.message;
toast.className = 'sync-toast sync-toast-err';
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
}
btn.innerHTML = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
async function stackAction(event, name, action) {
const btn = event.currentTarget;
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Folyamatban...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/stacks/' + name + '/' + action, {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (!data.ok) {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
return;
}
if (action === 'update') {
followUpdate(name, btn);
return;
}
window.location.reload();
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
}
function followUpdate(name, btn) {
var started = Date.now();
var timer = setInterval(async function() {
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
try {
var r = await fetch('/api/stacks/' + name);
var d = await r.json();
if (!d.ok || !d.data) return;
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
} catch (e) {}
}, 3000);
}
async function deleteOrphanStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'delete-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
document.body.appendChild(modal);
try {
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
var data = await resp.json();
var hddInfo = '';
var checkboxHTML = '';
if (data.ok && data.data && data.data.has_hdd_data) {
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
data.data.hdd_paths.forEach(function(p) {
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
});
hddInfo += '</div>';
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
hddInfo + checkboxHTML +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
'</div>';
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
}
}
function closeDeleteModal() {
var modal = document.getElementById('delete-modal');
if (modal) modal.remove();
}
async function confirmDelete(name) {
var btn = document.getElementById('confirm-delete-btn');
var checkbox = document.getElementById('delete-hdd-check');
var removeHDD = checkbox ? checkbox.checked : false;
btn.disabled = true;
btn.textContent = 'Törlés folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name, {
method: 'DELETE',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('delete-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen törölve!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') törölve lett.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Törlés';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Törlés';
}
}
async function removeStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'remove-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
document.body.appendChild(modal);
try {
var [hddResp, backupResp] = await Promise.all([
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
]);
var sections = '';
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
if (keepOnly) {
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.</div>';
}
sections += '<div class="modal-section">' +
'<strong>Mindig törlődik:</strong>' +
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
'<li>Telepítési konfiguráció (app.yaml)</li>' +
'<li>Másodlagos mentés ütemezése</li>' +
'</ul></div>';
var hddCheckbox = '';
if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
var hddPaths = '';
hddResp.data.hdd_paths.forEach(function(p) {
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
});
sections += '<div class="modal-section">' +
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
'Ha újratelepíti az alkalmazást, az adatokat újra importálnia kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
'</div></div>';
hddCheckbox = '\x3Cscript>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
}
var backupCheckbox = '';
if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) {
var bkPaths = '';
backupResp.data.backup_paths.forEach(function(p) {
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
});
if (bkPaths) {
sections += '<div class="modal-section">' +
'<strong>Mentési adatok:</strong>' + bkPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
'</div></div>';
}
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
sections +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
'</div>' + hddCheckbox;
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
}
}
function closeRemoveModal() {
var modal = document.getElementById('remove-modal');
if (modal) modal.remove();
}
async function confirmRemoveStack(name) {
var btn = document.getElementById('confirm-remove-btn');
var hddCheck = document.getElementById('remove-hdd-check');
var backupCheck = document.getElementById('remove-backup-check');
var removeHDD = hddCheck ? hddCheck.checked : false;
var removeBackups = backupCheck ? backupCheck.checked : false;
btn.disabled = true;
btn.textContent = 'Eltávolítás folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name + '/remove', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('remove-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen eltávolítva!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
}
</script>
</body>
</html>