diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e05c30..1084374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +## v0.279.0 — no app goes live with a login a stranger knows (after_install); an empty backup of a running app is an alarm; the night's chain on a button; R-706 (2026-09-28) + +**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged; Part D rides the existing operator-only +`backup_run_failures` digest). New strings: `app_info.known_login`, `backups_apps.hollow_local`, +`backups_apps.hollow_offsite`, `debug.night_chain` (hu + en). Evidence: `felhom.eu/documentation/audits/logins-nvme-2026-09-28/`. + +- **`after_install:` (`09` §3 decision 45).** A template may declare ONE command the box runs once in the app's own + container after a FRESH install (the deploy-done hook, ok=true) — to replace a known default login with a generated + `type: password` field, shown on the app page as the first password. `env:` names the deploy values filled into + `${NAME}` (an undeclared or empty one refuses — never an empty password); `success:` is a marker the output must + carry (claper's CLI exits 0 on an error). Retried while the app boots (6 × 20 s), recorded in `app.yaml` + (`after_install: {at, ok, detail}`), never logged expanded. Never on a restore or a kept-data load (R-694). The + deploy-done hook is now set on EVERY box (it was inside `if notifier != nil`, so a box with no hub would never run it). +- **The page says when a default login is in effect** (`defaultLoginInEffect`): the install dialog before the install + when nothing will replace it, the app page while it is in effect ("This app starts with a known, shared password: %s. + Change it right after the install."); the default-login card is HIDDEN once after_install replaced it. +- **Part D — a running app whose newest copy holds no data is an alarm.** At the end of the dump leg (the local unit) + and in the off-site loop (the pushed unit): an installed app that RUNS and has volumes, whose copy lists no dump and no + tar → the operator digest once per app per tier per day, and a sentence on the backup page until a later check finds + data. A held app that is stopped is not flagged. Measured before: yesterday's demo-hp nextcloud had a WARN line only. +- **R-705 (controller half):** debug action `POST /api/debug/backup/night-chain` — dump → Tier 2 → off-site → update leg, + one at a time, refused while a backup/restore op, a guarded update or another chain runs. The update leg gets its + normal length from its own start (`RunUpdateLegNow`; by day the night's W+5h has passed). +- **R-706:** a removal "with its backups" also deletes the app's off-site verification copy. +- Tests: `TestAfterInstall_*` (stacks + main.go wiring), `TestKnownLogin_*`, `TestPartD_*` (backup + page), + `TestR705_*` (web + stacks), `TestR706_*`. Red-proofs RP7–RP15, each seen failing on an assertion. + ## v0.278.0 — a hold left by an earlier install no longer holds the new one (R-704) (2026-09-28) **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: none. Evidence: diff --git a/CONTEXT.md b/CONTEXT.md index 6651301..bff0917 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,12 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-09-28 (v0.277.0 — kept data loads from the off-site copy too, R-691 (2)) +Last updated: 2026-09-28 evening (v0.279.0 — after_install / known default logins, Part D empty-backup alarm, night chain, R-706) + +> **2026-09-28 evening — v0.279.0.** `stacks/after_install.go` (decision 45: a fresh install replaces a known default +> login; the deploy-done hook now set on every box), `web/known_login.go` (the page's default-login rule), +> `backup/hollow_watch.go` (Part D: a running app's empty copy → digest + page), `web/night_chain.go` + `RunUpdateLegNow` +> (R-705), `Router.removeVerificationCopy` (R-706). Also: v0.278.0 (R-704) earlier the same day. > **2026-09-28 — v0.277.0 (MinAgent 0.131.0).** `backup/kept_load.go`: `KeptBestCopy` (local newest vs off-site — > off-site only when NEWER or the only copy), `KeptOffsiteCopies` (ONE `snapshots --json`, 20 s bound; `offsiteNewest` diff --git a/REUSE.md b/REUSE.md index e901493..f72e65b 100644 --- a/REUSE.md +++ b/REUSE.md @@ -25,6 +25,9 @@ | `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) | | `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, kept, legacy felhom-data) | Consult before ANY recursive delete under a drive | | `stacks.OldAppDataPaths` / `Manager.ListKept` / `KeepAside` / `DeleteKept` / `FindKept` | controller/internal/stacks/kept.go | `(composePath, hdd)` / `(drives)` / … | Kept data (`09` §3 decision 36): what counts as an app's old data (ONLY `/appdata/…` binds), the list, start-fresh, the household's delete | **An action names a kept item by path only through `FindKept`** — `DeleteKept` refuses anything not listed. `KeepAside` is a rename on one drive; never copy, never `RemoveAll` in a rollback (`removeEmptyDirs`) | +| `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 | +| `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` | | `Router.dropLeftoverHold` + `settings.ClearUpdateHold` (R-704, v0.278.0) | controller/internal/api/router.go · controller/internal/settings/settings.go | `(name, why)` / `(stack) (bool, error)` | A new install (plain or "use my kept data") and a removal clear the update / crash-loop hold of the app's install | **A hold belongs to an INSTALL; the name is all the next install shares with it.** Never clears an R-379 restore hold (operator-only) | | `backup.KeptBestCopy` / `KeptDBCopy` / `KeptOffsiteCopies` / `KeptCopyAt` / `LoadKeptApp` / `LoadKeptOffsite` / `KeptCopyKey` | controller/internal/backup/kept_load.go | `(ctx, app, drive)` / `(app, drive)` / `(ctx, apps)` / `(unitDir, drive, tier)` / … | Which copy can load kept files (local tiers + since v0.277.0 the off-site copy, R-691 (2)), the load as a restore op, the copy's name for the page | A copy counts only with data (DB dump or volume tar) AND its app.yaml `HDD_PATH` = this drive. Installed apps are never offered. **The off-site copy is judged only after its unit is downloaded** — `LoadKeptOffsite` refuses an unversioned unit (`07` §6.6) BEFORE `prepare` (a dated folder's move-back); ask the repository once per page (`KeptOffsiteCopies`), never per row. Both pages name a copy through `KeptCopyKey` | diff --git a/controller/README.md b/controller/README.md index 1f239a8..134b5e7 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1916,6 +1916,10 @@ that folder is never a dead end, and an install never runs into it silently (R-6 - **Where it lives.** `/kept/` is beside `appdata/` and `userdata/`, inside neither: no app bind, FileBrowser userdata source, Samba share or backup leg reads it. It is in `ProtectedHDDPaths`. - **The drive-full warning** ends with the kept folders on that drive and their sizes (`fillwatch.SetExtra`). +- **`after_install:` (v0.279.0, decision 45)** — `{service, user?, env: [NAMES], command: [...], success: MARKER}`: one + command in the app's own container after a FRESH install (never after a restore or a kept-data load), with the named + deploy values filled into `${NAME}`; the output must carry `success`. Recorded in `app.yaml` `after_install`; the app + page hides the default-login card once it succeeded and warns while a default login is in effect. - **R-704 (v0.278.0).** A new install (plain or "use my kept data") drops an update or crash-loop hold left by an EARLIER install of the app, and a removal clears both kinds; a restore hold (R-379) stays operator-cleared. - **R-690 (fixed here).** The removed-app restore (R-487) never found a unit on a DATA drive — it asked diff --git a/controller/cmd/controller/after_install_wiring_test.go b/controller/cmd/controller/after_install_wiring_test.go new file mode 100644 index 0000000..0a358e7 --- /dev/null +++ b/controller/cmd/controller/after_install_wiring_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// v0.279.0 (decision 45): the deploy-done hook — the ONE place a fresh install ends — runs after_install, and +// the hook is set on every box, not only inside `if notifier != nil` (a box with no hub still installs apps). +// COMPANION RED-PROOF: move SetDeployDoneHook back inside `if notifier != nil` → the second assertion fails; +// drop the RunAfterInstall call → the first fails. +func TestAfterInstall_IsWiredToEveryFreshInstall(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "main.go", nil, 0) + if err != nil { + t.Fatal(err) + } + var hook *ast.CallExpr + var insideNotifierIf bool + var walk func(n ast.Node, underNotifier bool) + walk = func(n ast.Node, underNotifier bool) { + ast.Inspect(n, func(x ast.Node) bool { + if ifs, ok := x.(*ast.IfStmt); ok { + if be, ok := ifs.Cond.(*ast.BinaryExpr); ok { + if id, ok := be.X.(*ast.Ident); ok && id.Name == "notifier" && be.Op == token.NEQ { + walk(ifs.Body, true) + if ifs.Else != nil { + walk(ifs.Else, underNotifier) + } + return false + } + } + } + if c, ok := x.(*ast.CallExpr); ok { + if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && hook == nil { + hook, insideNotifierIf = c, underNotifier + } + } + return true + }) + } + walk(f, false) + if hook == nil { + t.Fatal("SetDeployDoneHook is never called") + } + calls := false + ast.Inspect(hook, func(x ast.Node) bool { + if s, ok := x.(*ast.SelectorExpr); ok && s.Sel.Name == "RunAfterInstall" { + calls = true + } + return true + }) + if !calls { + t.Fatal("the deploy-done hook does not run RunAfterInstall") + } + if insideNotifierIf { + t.Fatal("SetDeployDoneHook is set only when a notifier exists — a box with no hub would never run after_install") + } +} diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 79b771d..7465ae2 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1191,6 +1191,18 @@ func main() { // It rides the EXISTING per-run digest (backup_run_failures) rather than a new event type: a // new type is a two-repo change (the hub's allowedEventTypes drops anything unlisted), and the // digest is already operator-only and already means "this run did not fully do its job". + // Part D (v0.279.0): a RUNNING app whose newest copy holds no database dump and no volume tar. Rides + // the same operator-only digest as R-203 (no new event type — the hub drops unlisted ones), once per + // app per tier per day (the backup package dedupes). + backupMgr.SetHollowCopyNotify(func(app, tier string) { + d := notify.BackupRunFailuresDetails{RunKind: "data-check", Attempted: 1, Failed: 1, + Apps: []notify.RunFailureDetail{{App: app, Leg: "hollow-" + tier, + Reason: "the app is running but its newest " + tier + " copy holds no database dump and no volume tar"}}} + notifier.NotifyBackupRunFailures(fmt.Sprintf( + "Backup holds NO DATA: %s is running, but its newest %s copy carries no database dump and no volume tar — "+ + "the app is not protected on that tier. Check for a hold on a running app (R-704's shape) or a skipped dump.", + app, tier), d) + }) backupMgr.SetOffboxGapNotify(func(gaps map[string][]string) { apps := make([]string, 0, len(gaps)) for app := range gaps { @@ -1749,19 +1761,31 @@ func main() { // R-536: „Alkalmazás telepítve" is sent when the async deploy ACTUALLY finishes, and a deploy // that ends badly now says so instead of being silence. The accept-time event is // `app_deploy_started`, emitted by the API router beside its 202. - if notifier != nil { - stackMgr.SetDeployDoneHook(func(name string, ok bool, detail string) { - display := name - if s, found := stackMgr.GetStack(name); found && s.Meta.DisplayName != "" { - display = s.Meta.DisplayName - } - if ok { + // v0.279.0: the hook is set on EVERY box — after_install (decision 45) must run on a box with no hub too; + // only the events need the notifier. + stackMgr.SetDeployDoneHook(func(name string, ok bool, detail string) { + display := name + if s, found := stackMgr.GetStack(name); found && s.Meta.DisplayName != "" { + display = s.Meta.DisplayName + } + if ok { + if notifier != nil { notifier.NotifyAppDeployed(name, display) - return } + // v0.279.0 (decision 45): a FRESH install replaces a known default login with its generated one, + // when the template declares after_install. Never on a restore or a kept-data load — those do not + // pass through the deploy-done hook. + go func() { + if ran, err := stackMgr.RunAfterInstall(name, 10*time.Minute); ran && err != nil { + log.Printf("[WARN] [stacks] after_install %s: %v", name, err) + } + }() + return + } + if notifier != nil { notifier.NotifyAppDeployFailed(name, display, detail) - }) - } + } + }) // R-681 (v0.270.0): an install a restart cut off is finished through the failure path and reported — // AFTER the deploy-done hook exists, so its event is sent. if names := stackMgr.RecoverInterruptedInstalls(); len(names) > 0 { diff --git a/controller/internal/api/r706_verify_copy_test.go b/controller/internal/api/r706_verify_copy_test.go new file mode 100644 index 0000000..27d96f8 --- /dev/null +++ b/controller/internal/api/r706_verify_copy_test.go @@ -0,0 +1,76 @@ +package api + +import ( + "go/ast" + "go/parser" + "go/token" + "io" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-706 (v0.279.0) — a removal "with its backups" also deletes the app's off-site verification copy, and +// ONLY that app's. COMPANION RED-PROOF: remove the removeVerificationCopy call from removeStack → the AST +// check fails; make the helper skip the delete → the copy is still on disk. +func TestR706_RemovalWithBackupsDeletesTheVerificationCopy(t *testing.T) { + dir := t.TempDir() + drive := filepath.Join(dir, "drive") + cfg := &config.Config{} + cfg.Paths.DataDir = filepath.Join(dir, "data") + _ = os.MkdirAll(cfg.Paths.DataDir, 0o755) + _ = os.MkdirAll(drive, 0o755) + sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil { + t.Fatal(err) + } + bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0)) + mine, other := bm.OffsiteRestoreScratchPath("cloudapp"), bm.OffsiteRestoreScratchPath("otherapp") + if mine == "" || other == "" { + t.Fatalf("no verification-copy location resolved (%q, %q)", mine, other) + } + for _, p := range []string{mine, other} { + _ = os.MkdirAll(p, 0o755) + _ = os.WriteFile(filepath.Join(p, "restored.bin"), []byte("x"), 0o644) + } + r := &Router{backupMgr: bm, logger: log.New(io.Discard, "", 0)} + got := r.removeVerificationCopy("cloudapp") + if len(got) != 1 || !strings.HasPrefix(got[0], mine) { + t.Fatalf("reported %v, want the copy at %s", got, mine) + } + if _, err := os.Stat(mine); !os.IsNotExist(err) { + t.Fatalf("the verification copy is still on disk: %v", err) + } + if _, err := os.Stat(filepath.Join(other, "restored.bin")); err != nil { + t.Fatalf("another app's verification copy was touched: %v", err) + } + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "router.go", nil, 0) + if err != nil { + t.Fatal(err) + } + called := false + for _, d := range f.Decls { + if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil { + ast.Inspect(fn.Body, func(n ast.Node) bool { + if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "removeVerificationCopy" { + called = true + } + return true + }) + } + } + if !called { + t.Fatal("removeStack does not call removeVerificationCopy (R-706)") + } +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index e810e64..d03dc4b 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -929,6 +929,29 @@ func (r *Router) dropLeftoverHold(name, why string) { } } +// removeVerificationCopy (R-706, v0.279.0) deletes the app's off-site VERIFICATION copy +// (`backups/offsite-restore/`, left by a full off-site restore for the household to inspect) when the +// app is removed "with its backups". Measured 2026-09-28 on demo-hp: ~1 GB stayed after such a removal, and +// the app list no longer showed anything it belonged to. Returns the removed path as the removal reports it. +// Pinned by TestR706_RemovalWithBackupsDeletesTheVerificationCopy. +func (r *Router) removeVerificationCopy(name string) []string { + if r.backupMgr == nil { + return nil + } + var out []string + for _, c := range r.backupMgr.ListOffsiteRestoreCopies() { + if c.Stack != name { + continue + } + if err := r.backupMgr.DeleteOffsiteRestoreCopy(name); err != nil { + r.logger.Printf("[WARN] [api] remove %s: its off-site verification copy %s could not be deleted: %v", name, c.Path, err) + continue + } + out = append(out, fmt.Sprintf("%s (%s)", c.Path, c.SizeHuman)) + } + return out +} + func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) { if name == "" { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"}) @@ -1008,6 +1031,9 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri if body.RemoveBackups && r.backupMgr != nil && len(mirrorDirs) > 0 { resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveTier2Mirrors(name, mirrorDirs)...) } + if body.RemoveBackups { + resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.removeVerificationCopy(name)...) + } // R-486 (v0.240.0): the app's backup preferences — and with them the Tier-2 RECORD that // tier2RecordedCopyDir needs — are forgotten ONLY when the customer asked for the backups to be diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 4cdbd93..332c416 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -160,6 +160,9 @@ type Manager struct { // INIT/TEST ONLY. keptUnitRestoreFn func(app, unitDir string) (UnitRestoreResult, error) + // hollow (Part D, v0.279.0) — the running apps whose newest copy holds no data. See hollow_watch.go. + hollow hollowWatch + // F17 restore seams — overridable in tests so the .sql re-import orchestration can be unit-tested // without Docker. Default to the real DiscoverDatabases / ImportDump (lazy-init in reimportDBDumps). discoverDBs func(ctx context.Context) ([]DiscoveredDB, error) @@ -667,6 +670,8 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error { // Phase 2: refresh each deployed app's self-contained recovery unit (compose + manifest). A DATA run: // the capture folds the stamps the legs above just wrote (v0.275.0). m.captureAllRecoveryUnits(true) + // Part D (v0.279.0): a RUNNING app whose unit still holds no data after this run is an alarm. + m.checkLocalCopies() // F5 (CAMPAIGN-3): after the units are fresh on the CURRENT drives, prune any orphaned // backups/primary/ dir an app left on an OLD drive when its HDD_PATH moved — pure disk diff --git a/controller/internal/backup/hollow_watch.go b/controller/internal/backup/hollow_watch.go new file mode 100644 index 0000000..87a4163 --- /dev/null +++ b/controller/internal/backup/hollow_watch.go @@ -0,0 +1,137 @@ +package backup + +import ( + "sort" + "sync" + "time" +) + +// ── Part D (v0.279.0): a RUNNING app whose newest copy holds no data is an alarm ───────────────────── +// +// Measured 2026-09-28 on demo-hp (controller 0.277.0): a freshly installed nextcloud carried a leftover +// hold (R-704), so the dump leg skipped its volumes and its unit was never captured; the off-site run then +// pushed a snapshot that "carried NO database dump and NO volume tar". The ONLY trace was one WARN line in +// the container log (R-412 leg 1 made it honest wording, nothing more): no event, no page sentence. The app +// was running and unprotected, and nobody could know. +// +// R-704 closed that cause. This closes the CLASS: at the end of each data leg, every installed app that is +// RUNNING and has named volumes (the data a unit must carry — a database lives in a volume) must have a +// copy that carries data (unitCarriesData — the manifest lists a dump or a tar). If not: +// - the operator hears it once per app, per tier, per day (the existing operator-only backup_run_failures +// digest, wired in main.go — no new event type); +// - the household sees one sentence per app on the backup page, until a later check finds data. +// A HELD app that is really stopped is not flagged — a hold is a deliberate stop, and its copy is the one +// the hold names. A held app that RUNS (yesterday's shape) is flagged. +// Pinned by internal/backup/r_partd_hollow_watch_test.go. + +// Hollow-copy tiers. +const ( + HollowTierLocal = "local" // the app's own recovery unit (Tier 1), checked at the end of the dump leg + HollowTierOffsite = "offsite" // the unit the off-site run just pushed (Tier 3) +) + +// HollowCopy is one running app whose newest copy on a tier holds no data. +type HollowCopy struct { + App string + Tier string + At time.Time // when the check found it +} + +type hollowWatch struct { + mu sync.Mutex + current map[string]HollowCopy // key app|tier + notified map[string]string // key app|tier → the day (YYYY-MM-DD) the operator was told + notify func(app, tier string) + now func() time.Time +} + +// SetHollowCopyNotify wires the operator signal (main.go → notifier). INIT-ONLY. +func (m *Manager) SetHollowCopyNotify(fn func(app, tier string)) { + m.hollow.mu.Lock() + m.hollow.notify = fn + m.hollow.mu.Unlock() +} + +// HollowCopies returns the flagged apps, sorted, for the backup page. +func (m *Manager) HollowCopies() []HollowCopy { + m.hollow.mu.Lock() + defer m.hollow.mu.Unlock() + out := make([]HollowCopy, 0, len(m.hollow.current)) + for _, h := range m.hollow.current { + out = append(out, h) + } + sort.Slice(out, func(i, j int) bool { + if out[i].App != out[j].App { + return out[i].App < out[j].App + } + return out[i].Tier < out[j].Tier + }) + return out +} + +// watchesForData: a deployed app that RUNS and has named volumes. A held app that is stopped is skipped. +func (m *Manager) watchesForData(app string) bool { + if m.stackProvider == nil || m.cfg != nil && m.cfg.IsProtectedStack(app) { + return false + } + if len(m.stackProvider.GetDockerVolumes(app)) == 0 { + return false + } + return m.stackProvider.RefreshAndIsRunning(app) +} + +// judgeCopy records the verdict for one app's copy on a tier: flags (and tells the operator, once a day) a +// running app's copy with no data, clears the flag when the copy carries data. +func (m *Manager) judgeCopy(app, tier, unitDir string) { + key := app + "|" + tier + hollow := m.watchesForData(app) && !unitCarriesData(unitDir) + m.hollow.mu.Lock() + if m.hollow.current == nil { + m.hollow.current = map[string]HollowCopy{} + m.hollow.notified = map[string]string{} + } + now := time.Now + if m.hollow.now != nil { + now = m.hollow.now + } + if !hollow { + delete(m.hollow.current, key) + m.hollow.mu.Unlock() + return + } + t := now() + m.hollow.current[key] = HollowCopy{App: app, Tier: tier, At: t} + day := t.Format("2006-01-02") + tell := m.hollow.notify != nil && m.hollow.notified[key] != day + if tell { + m.hollow.notified[key] = day + } + fn := m.hollow.notify + m.hollow.mu.Unlock() + m.logger.Printf("[ERROR] [backup] %s is RUNNING but its newest %s copy (%s) holds NO database dump and NO volume tar — it is not protected (Part D)", app, tier, unitDir) + if tell { + fn(app, tier) + } +} + +// checkLocalCopies judges every deployed app's own unit at the end of the dump leg. +func (m *Manager) checkLocalCopies() { + if m.stackProvider == nil { + return + } + for _, s := range m.stackProvider.ListDeployedStacks() { + m.judgeCopy(s.Name, HollowTierLocal, m.primaryUnitDirFor(s.Name)) + } +} + +// FlagHollowCopyForTest records a flagged copy without a backup run (the web package's render test). +// Test-only by name: only judgeCopy may decide a copy is hollow. +func (m *Manager) FlagHollowCopyForTest(app, tier string) { + m.hollow.mu.Lock() + defer m.hollow.mu.Unlock() + if m.hollow.current == nil { + m.hollow.current = map[string]HollowCopy{} + m.hollow.notified = map[string]string{} + } + m.hollow.current[app+"|"+tier] = HollowCopy{App: app, Tier: tier, At: time.Now()} +} diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index e9648de..3507619 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -1384,6 +1384,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin // WORDING ONLY. This adds no guard and does NOT touch the capture — `07` §8.2 records why // guarding the capture would make the manifest lie. The reader of a log gets the fact; whether // the push should refuse is R-412 leg 2 and is still open. + m.judgeCopy(stack, HollowTierOffsite, src) // Part D (v0.279.0): a running app's empty snapshot is an alarm if unitIsHollow(src) { m.logger.Printf("[WARN] [offbox] backed up %s (%s, %d mandatory path(s)) — but the recovery unit carried NO database dump and NO volume tar, so this snapshot holds none of the app's data; the next run with a dump leg will replace it", stack, src, len(extra)) diff --git a/controller/internal/backup/r_partd_hollow_watch_test.go b/controller/internal/backup/r_partd_hollow_watch_test.go new file mode 100644 index 0000000..b21b0c8 --- /dev/null +++ b/controller/internal/backup/r_partd_hollow_watch_test.go @@ -0,0 +1,91 @@ +package backup + +import ( + "encoding/json" + "io" + "log" + "os" + "path/filepath" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// hollowProvider: per-app volumes and running state; everything else from floorProvider. +type hollowProvider struct { + floorProvider + volumes map[string][]string + running map[string]bool +} + +func (p *hollowProvider) GetDockerVolumes(n string) []string { return p.volumes[n] } +func (p *hollowProvider) RefreshAndIsRunning(n string) bool { return p.running[n] } + +// Part D (v0.279.0) — the CONSEQUENCE: a running app whose newest copy holds no data reaches the operator +// (once per app per tier per day) and the backup page; a copy with data clears it; a stopped (held) app +// and an app with no volumes are never flagged. +// COMPANION RED-PROOF: make judgeCopy ignore unitCarriesData (the pre-0.279.0 shape: nothing but a log) → +// no notification and the first assertion fails. +func TestPartD_ARunningAppWithAnEmptyCopyIsAnAlarm(t *testing.T) { + sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.SystemDataPath = t.TempDir() + m := NewManager(cfg, sett, log.New(io.Discard, "", 0)) + p := &hollowProvider{floorProvider: floorProvider{stacks: []string{"cloud", "held", "novol"}, dir: t.TempDir()}, + volumes: map[string][]string{"cloud": {"cloud_db"}, "held": {"held_db"}}, + running: map[string]bool{"cloud": true, "novol": true}} + m.SetStackProvider(p) + day := time.Date(2026, 9, 28, 15, 20, 0, 0, time.UTC) + m.hollow.now = func() time.Time { return day } + var told []string + m.SetHollowCopyNotify(func(app, tier string) { told = append(told, app+"/"+tier) }) + + unit := t.TempDir() + writeMan := func(withData bool) { + man := RecoveryManifest{SchemaVersion: 2, AppName: "cloud"} + if withData { + man.DBDumps = []string{"cloud.sql"} + } + b, _ := json.Marshal(man) + _ = os.WriteFile(UnitManifestFile(unit), b, 0o644) + } + writeMan(false) // yesterday's shape: a manifest listing nothing + + for _, app := range []string{"cloud", "held", "novol"} { + m.judgeCopy(app, HollowTierOffsite, unit) + } + if len(told) != 1 || told[0] != "cloud/offsite" { + t.Fatalf("operator told %v, want exactly [cloud/offsite] (held+stopped and volume-less apps are not flagged)", told) + } + if h := m.HollowCopies(); len(h) != 1 || h[0].App != "cloud" || h[0].Tier != HollowTierOffsite { + t.Fatalf("page list %+v, want the one running app", h) + } + // Same day again: still flagged, NOT told twice. + m.judgeCopy("cloud", HollowTierOffsite, unit) + if len(told) != 1 { + t.Fatalf("told twice in one day: %v", told) + } + // A missing unit (no manifest) is also no data — fail closed. + m.judgeCopy("cloud", HollowTierLocal, filepath.Join(unit, "absent")) + if len(told) != 2 || told[1] != "cloud/local" { + t.Fatalf("an absent local unit was not flagged: %v", told) + } + // The next day: told again. + m.hollow.now = func() time.Time { return day.Add(24 * time.Hour) } + m.judgeCopy("cloud", HollowTierOffsite, unit) + if len(told) != 3 { + t.Fatalf("not told again the next day: %v", told) + } + // The copy carries data again: cleared from the page. + writeMan(true) + m.judgeCopy("cloud", HollowTierOffsite, unit) + m.judgeCopy("cloud", HollowTierLocal, unit) + if h := m.HollowCopies(); len(h) != 0 { + t.Fatalf("a copy with data is still listed: %+v", h) + } +} diff --git a/controller/internal/backupwindow/backupwindow.go b/controller/internal/backupwindow/backupwindow.go index 498ade9..b1bf47a 100644 --- a/controller/internal/backupwindow/backupwindow.go +++ b/controller/internal/backupwindow/backupwindow.go @@ -26,6 +26,11 @@ const ( // gate — one constant, so the two can never disagree (TestLegDeadlineAndGateShareW5h). const UpdateLegStopOffsetMin = 300 +// UpdateLegLengthMin is how long the leg may start steps on a normal night: from the off-site leg (W+105m), +// after which it is chained, to W+5h. A MANUAL run of the night's chain (R-705) gives the leg this same +// length, counted from when it starts. +const UpdateLegLengthMin = UpdateLegStopOffsetMin - offboxOffsetMin + // ParseHHMM parses "HH:MM" (24h) into minutes-since-midnight. It rejects anything but a valid // hour:minute — the same contract as the scheduler's parseDailyTime, kept here so this package is // dependency-free and reusable by the quiesce gate. diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index c965dd9..9977980 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -111,6 +111,7 @@ "app_import.tarolo": "Storage", "app_import.titkos": "Encrypted", "app_import.visszaallitas_inditasa": "Start restore", + "app_info.known_login": "This app starts with a known, shared password: %s. Change it right after the install.", "app_info.a_kezdeti_jelszo_beolvasasa_nem": "The first password could not be read.", "app_info.a_regi_adatok_erintetlenek": "The old data is untouched.", "app_info.adatait_ide": "data to:", @@ -233,6 +234,8 @@ "backups.utolso_sikeres_mentes": "✓ Last successful backup: {{fmtTime .SuccessAt}} ({{timeAgo .SuccessAt}})", "backups.utolso_teljes_mentes": "Last full backup", "backups.visszaallitas_ellenorizve": "Restore checked", + "backups_apps.hollow_local": "%s is running, but its newest local backup holds no data (no database, no data volume). So it is not protected now. Felhom knows about it.", + "backups_apps.hollow_offsite": "%s is running, but its newest off-site copy holds no data (no database, no data volume). So it is not protected off-site now. Felhom knows about it.", "backups_apps.1_mentes": "Backup 1", "backups_apps.1_mentes_auto": "Backup 1 auto", "backups_apps.2_mentes": "Backup 2", @@ -899,6 +902,7 @@ "datapath.consequence.import_excluded": "Copy the files to be processed in here. The app reads them and then deletes them from here — this folder is temporary and is not backed up.", "datapath.consequence.kept": "This is where your files are kept. It is backed up.", "datapath.free_space": "%.1f GB free", + "debug.night_chain": "Tonight's chain now (dump → second drive → off-site → update)", "debug.aktiv": "Active", "debug.aktiv_feladat": "Active job", "debug.alkalmazas": "App", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index e818675..ac7ca92 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -107,6 +107,7 @@ "app_import.tarolo": "Tároló", "app_import.titkos": "Titkos", "app_import.visszaallitas_inditasa": "Visszaállítás indítása", + "app_info.known_login": "Ez az alkalmazás egy ismert, közös jelszóval indul: %s. Telepítés után azonnal változtasd meg.", "app_info.a_kezdeti_jelszo_beolvasasa_nem": "A kezdeti jelszó beolvasása nem sikerült.", "app_info.a_regi_adatok_erintetlenek": "A régi adatok érintetlenek.", "app_info.adatait_ide": "adatait ide:", @@ -229,6 +230,8 @@ "backups.utolso_sikeres_mentes": "✓ Utolsó sikeres mentés: {{fmtTime .SuccessAt}} ({{timeAgo .SuccessAt}})", "backups.utolso_teljes_mentes": "Utolsó teljes mentés", "backups.visszaallitas_ellenorizve": "Visszaállítás ellenőrizve", + "backups_apps.hollow_local": "A(z) %s fut, de a legutóbbi helyi mentése nem tartalmaz adatot (se adatbázist, se adatkötetet). Így most nincs védve. A Felhom tud róla.", + "backups_apps.hollow_offsite": "A(z) %s fut, de a legutóbbi távoli mentése nem tartalmaz adatot (se adatbázist, se adatkötetet). Így most nincs védve távol. A Felhom tud róla.", "backups_apps.1_mentes": "1. mentés", "backups_apps.1_mentes_auto": "1. mentés auto", "backups_apps.2_mentes": "2. mentés", @@ -894,6 +897,7 @@ "datapath.consequence.import_excluded": "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — ez a mappa átmeneti, és nem készül róla biztonsági mentés.", "datapath.consequence.kept": "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla.", "datapath.free_space": "%.1f GB szabad", + "debug.night_chain": "Az éjszakai lánc most (mentés → 2. meghajtó → távoli → frissítés)", "debug.aktiv": "Aktív", "debug.aktiv_feladat": "Aktív feladat", "debug.alkalmazas": "Alkalmazás", diff --git a/controller/internal/stacks/after_install.go b/controller/internal/stacks/after_install.go new file mode 100644 index 0000000..ad073bc --- /dev/null +++ b/controller/internal/stacks/after_install.go @@ -0,0 +1,154 @@ +package stacks + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// ── after_install (v0.279.0, `09` §3 decision 45): no app is published with a login a stranger knows ── +// +// Some apps start with a known, shared admin password (claper seeds admin@claper.co / claper at every first +// start — measured on 9202 2026-09-28, R-702). The box publishes every app on the household's domain. So +// where the app's OWN CLI or API can change it, the template declares ONE command the box runs once, in the +// app's own container, right after a FRESH install: +// +// after_install: +// service: claper +// env: [ADMIN_PASSWORD] # deploy values filled into the command; nothing else is +// command: ["/app/bin/claper", "rpc", "... ${ADMIN_PASSWORD} ..."] +// success: FELHOM_AFTER_INSTALL_OK # the command's output must carry this, or it failed +// +// The value is a generated `type: password` field, so the household sees it on the app page as the first +// password (the grafana/code-server pattern). +// +// ONLY after a fresh install (the deploy-done hook, ok=true). NEVER after a restore or "use my kept data": +// there the login comes back with the data, and changing it would lock the household out (R-694). A failed +// command is retried while the app boots, then recorded (`after_install` in app.yaml) and shown on the app +// page; the app stays installed and running — never half-installed. The expanded command is never logged +// (it carries the password); the log names the template. +// Pinned by internal/stacks/after_install_test.go. + +// AfterInstallCommand is `.felhom.yml`'s `after_install:`. +type AfterInstallCommand struct { + Service string `yaml:"service" json:"service"` + User string `yaml:"user,omitempty" json:"user,omitempty"` + Env []string `yaml:"env,omitempty" json:"env,omitempty"` + Command []string `yaml:"command" json:"command"` + Success string `yaml:"success" json:"success"` +} + +// AfterInstallRecord is app.yaml's `after_install:` — what the one-time command did. +type AfterInstallRecord struct { + At string `yaml:"at" json:"at"` + OK bool `yaml:"ok" json:"ok"` + Detail string `yaml:"detail,omitempty" json:"detail,omitempty"` +} + +// afterInstallTries / afterInstallGap: the app may still be booting when the deploy reports done. +var ( + afterInstallTries = 6 + afterInstallGap = 20 * time.Second +) + +// expandAfterInstall fills ${NAME} for the declared env names only, from the app's env. An undeclared or +// empty name refuses — a command with a hole must never run (it could set an EMPTY password). +func expandAfterInstall(cmd []string, allowed []string, env map[string]string) ([]string, error) { + ok := map[string]bool{} + for _, n := range allowed { + ok[n] = true + } + var missing []string + out := make([]string, len(cmd)) + for i, a := range cmd { + out[i] = os.Expand(a, func(k string) string { + if !ok[k] || env[k] == "" { + missing = append(missing, k) + return "" + } + return env[k] + }) + } + if len(missing) > 0 { + return nil, fmt.Errorf("after_install: %v not declared in env or has no value — not run", missing) + } + return out, nil +} + +// RunAfterInstall runs the app's after_install once, after a FRESH install (main.go's deploy-done hook). +// Returns (ran, error). Records the outcome in app.yaml either way. +func (m *Manager) RunAfterInstall(name string, wait time.Duration) (bool, error) { + st, ok := m.GetStack(name) + if !ok { + return false, fmt.Errorf("stack %q not found", name) + } + ai := st.Meta.AfterInstall + if ai == nil || ai.Service == "" || len(ai.Command) == 0 || ai.Success == "" { + return false, nil + } + dir := filepath.Dir(st.ComposePath) + record := func(ok bool, detail string) { + rec := &AfterInstallRecord{At: m.now().UTC().Format(time.RFC3339), OK: ok, Detail: truncateStr(detail, 300)} + m.mutateAppConfig(name, dir, "after_install", func(cfg *AppConfig) bool { cfg.AfterInstall = rec; return true }) + } + cfg := LoadAppConfigDecrypted(dir, m.encKey) + if cfg == nil { + record(false, "the app's settings could not be read") + return true, fmt.Errorf("after_install %s: app.yaml unreadable", name) + } + cmd, err := expandAfterInstall(ai.Command, ai.Env, cfg.Env) + if err != nil { + m.logger.Printf("[ERROR] [stacks] %s: %v", name, err) + record(false, err.Error()) + return true, err + } + deadline := time.Now().Add(wait) + for { + _ = m.RefreshStatus() + if s, ok := m.GetStack(name); ok && (s.State == StateRunning || s.State == StateUnhealthy) { + break + } + if time.Now().After(deadline) { + record(false, "the app did not start in time") + return true, fmt.Errorf("after_install %s: the app did not start within %s — not run", name, wait) + } + time.Sleep(5 * time.Second) + } + return true, m.runAfterInstallNow(name, ai, cmd, record) +} + +// runAfterInstallNow runs the expanded command (RunAfterInstall has waited for the app): retries while the +// output lacks the success marker, then records the outcome. +func (m *Manager) runAfterInstallNow(name string, ai *AfterInstallCommand, cmd []string, record func(ok bool, detail string)) error { + dir := "" + if st, ok := m.GetStack(name); ok { + dir = filepath.Dir(st.ComposePath) + } + args := []string{"exec", "-T"} + if ai.User != "" { + args = append(args, "-u", ai.User) + } + args = append(args, ai.Service) + args = append(args, cmd...) + var last string + for try := 1; try <= afterInstallTries; try++ { + t0 := time.Now() + out, err := m.afterLoadExec(dir, args...) + if err == nil && strings.Contains(out, ai.Success) { + m.logger.Printf("[INFO] [stacks] after_install %s: %s %v done in %s (try %d) — the app's known first login is replaced by its generated one", + name, ai.Service, ai.Command, time.Since(t0).Round(time.Millisecond), try) + record(true, "") + return nil + } + last = fmt.Sprintf("try %d: err=%v, no %q in the output", try, err, ai.Success) + m.logger.Printf("[WARN] [stacks] after_install %s: %s %v %s", name, ai.Service, ai.Command, last) + if try < afterInstallTries { + time.Sleep(afterInstallGap) + } + } + m.logger.Printf("[ERROR] [stacks] after_install %s FAILED after %d tries — the app runs with its KNOWN default login; the app page says so", name, afterInstallTries) + record(false, last) + return fmt.Errorf("after_install %s failed: %s", name, last) +} diff --git a/controller/internal/stacks/after_install_test.go b/controller/internal/stacks/after_install_test.go new file mode 100644 index 0000000..c5b5a87 --- /dev/null +++ b/controller/internal/stacks/after_install_test.go @@ -0,0 +1,91 @@ +package stacks + +import ( + "bytes" + "errors" + "log" + "path/filepath" + "strings" + "testing" + "time" +) + +// v0.279.0 (decision 45) — after_install: the one command after a FRESH install that replaces a known default +// login with the generated one. The exec is the seam; nothing reaches Docker. +// COMPANION RED-PROOFS: (1) drop the success-marker check in runAfterInstallNow → a failing command is +// recorded ok and the second case fails; (2) make expandAfterInstall allow an undeclared/empty name → the +// command runs with a hole and the third case fails. +func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) { + m, _ := keptManager(t) + var logBuf bytes.Buffer + m.logger = log.New(&logBuf, "", 0) + afterInstallTries, afterInstallGap = 3, 0 + t.Cleanup(func() { afterInstallTries, afterInstallGap = 6, 20*time.Second }) + dir := filepath.Dir(m.stacks["cloudapp"].ComposePath) + must(t, SaveAppConfig(dir, &AppConfig{Deployed: true, Env: map[string]string{"ADMIN_PASSWORD": "Gen3r4tedValue"}}, m.encKey, nil)) + ai := &AfterInstallCommand{Service: "cloudapp", Env: []string{"ADMIN_PASSWORD"}, + Command: []string{"/app/bin/tool", "set-admin", "${ADMIN_PASSWORD}"}, Success: "FELHOM_OK"} + m.mu.Lock() + m.stacks["cloudapp"].Meta.AfterInstall = ai + m.mu.Unlock() + record := func(ok bool, d string) { + m.mutateAppConfig("cloudapp", dir, "after_install", func(c *AppConfig) bool { + c.AfterInstall = &AfterInstallRecord{At: "t", OK: ok, Detail: d} + return true + }) + } + + // 1. Success: the value is filled in, the command runs ONCE, the record says ok, the log never carries it. + var calls [][]string + m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "... FELHOM_OK", nil } + cmd, err := expandAfterInstall(ai.Command, ai.Env, map[string]string{"ADMIN_PASSWORD": "Gen3r4tedValue"}) + must(t, err) + if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err != nil { + t.Fatal(err) + } + if len(calls) != 1 || strings.Join(calls[0], " ") != "exec -T cloudapp /app/bin/tool set-admin Gen3r4tedValue" { + t.Fatalf("ran %v", calls) + } + if c := LoadAppConfig(dir); c.AfterInstall == nil || !c.AfterInstall.OK { + t.Fatalf("record: %+v", c.AfterInstall) + } + if strings.Contains(logBuf.String(), "Gen3r4tedValue") { + t.Fatal("the generated password reached the log") + } + + // 2. No success marker: retried, then recorded as FAILED — never recorded ok. + calls = nil + m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "boom", errors.New("exit 1") } + if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err == nil { + t.Fatal("a failing command reported success") + } + if len(calls) != 3 { + t.Fatalf("tries %d, want 3", len(calls)) + } + if c := LoadAppConfig(dir); c.AfterInstall == nil || c.AfterInstall.OK { + t.Fatalf("a failure was recorded as ok: %+v", c.AfterInstall) + } + + // 2b. Exit 0 WITHOUT the marker (claper's `rpc` exits 0 on an Elixir error): still a failure. + calls = nil + m.afterLoadFn = func(_ string, args ...string) (string, error) { + calls = append(calls, args) + return "FELHOM_AFTER_INSTALL_FAILED {:error, changeset}", nil + } + if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err == nil { + t.Fatal("an exit-0 command without the success marker reported success") + } + if c := LoadAppConfig(dir); c.AfterInstall == nil || c.AfterInstall.OK { + t.Fatalf("an exit-0 failure was recorded as ok: %+v", c.AfterInstall) + } + + // 3. A value that is not declared, or empty, refuses BEFORE anything runs (never an empty password). + for _, env := range []map[string]string{{}, {"ADMIN_PASSWORD": ""}} { + if _, err := expandAfterInstall(ai.Command, ai.Env, env); err == nil { + t.Fatalf("expanded with env %v", env) + } + } + if _, err := expandAfterInstall([]string{"x ${OTHER}"}, ai.Env, map[string]string{"OTHER": "v"}); err == nil { + t.Fatal("an undeclared name was filled in") + } +} diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 82e055d..62b83da 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -179,6 +179,8 @@ type AppConfig struct { // removed app, a rebuilt guest) while the app's own login came back with its data. The page then shows // no value for them and says the old password is the one that works (restoredLoginFields). RestoredLogins []string `yaml:"restored_logins,omitempty" json:"restored_logins,omitempty"` + // AfterInstall (v0.279.0, decision 45) is what the template's one-time after_install command did. + AfterInstall *AfterInstallRecord `yaml:"after_install,omitempty" json:"after_install,omitempty"` } // InstalledImage is one compose service's observed image. See AppConfig.InstalledImages. diff --git a/controller/internal/stacks/life_records.go b/controller/internal/stacks/life_records.go index 575315e..61f91e3 100644 --- a/controller/internal/stacks/life_records.go +++ b/controller/internal/stacks/life_records.go @@ -31,6 +31,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) { cfg.FailedStep = prior.FailedStep cfg.LastUpdateUndone = prior.LastUpdateUndone cfg.LastAutoUpdate = prior.LastAutoUpdate + cfg.AfterInstall = prior.AfterInstall // v0.279.0: what the install's one-time command did stays true after a restore if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 { cur := "" if prior.ConversionCopy != nil { diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index e967b5a..b2706c6 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -52,8 +52,11 @@ type Metadata struct { // app's kept data (a database from a backup under files kept on the drive) — for an app whose own // index of its files must be rebuilt. Measured on nextcloud: a file written after the backup is on // the drive and invisible until `occ files:scan --all`. Optional; absent = nothing runs. - AfterLoad *AfterLoadCommand `yaml:"after_load,omitempty" json:"after_load,omitempty"` - Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"` + AfterLoad *AfterLoadCommand `yaml:"after_load,omitempty" json:"after_load,omitempty"` + // AfterInstall (v0.279.0, `09` §3 decision 45) is ONE command the box runs once after a FRESH install — + // to replace a known default login with the generated one. See after_install.go. + AfterInstall *AfterInstallCommand `yaml:"after_install,omitempty" json:"after_install,omitempty"` + Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"` // InitialCreds: for apps that auto-generate a first-login credential into a file inside the // container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and // surfaces it on the app page, so the customer never has to dig through logs. Optional. diff --git a/controller/internal/stacks/unattended.go b/controller/internal/stacks/unattended.go index 98046d6..4f19c9b 100644 --- a/controller/internal/stacks/unattended.go +++ b/controller/internal/stacks/unattended.go @@ -217,6 +217,17 @@ func LegDeadline(now time.Time, window string, loc *time.Location) time.Time { // RunUpdateLeg runs one night's leg and returns its summary (nil when another leg is running or the leg // is not wired). trigger names the caller for the log ("after-offsite"). func (m *Manager) RunUpdateLeg(ctx context.Context, trigger string) *UpdateLegSummary { + return m.runUpdateLeg(ctx, trigger, false) +} + +// RunUpdateLegNow is the leg for a MANUAL run of the night's chain (R-705, the debug action): the same +// leg, but its step deadline is its start + the leg's normal length (backupwindow.UpdateLegLengthMin), +// not W+5h of the last window — which, by day, has passed and would make the leg start nothing. +func (m *Manager) RunUpdateLegNow(ctx context.Context, trigger string) *UpdateLegSummary { + return m.runUpdateLeg(ctx, trigger, true) +} + +func (m *Manager) runUpdateLeg(ctx context.Context, trigger string, manual bool) *UpdateLegSummary { if !m.leg.run.TryLock() { m.logger.Printf("[WARN] [update-leg] a leg is already running — this call (%s) does nothing", trigger) return nil @@ -249,6 +260,9 @@ func (m *Manager) RunUpdateLeg(ctx context.Context, trigger string) *UpdateLegSu window = o.WindowStart() } sum.Deadline = LegDeadline(sum.StartedAt, window, o.Location) + if manual { + sum.Deadline = sum.StartedAt.Add(time.Duration(backupwindow.UpdateLegLengthMin) * time.Minute) + } m.setLegFlags(true, false) defer m.setLegFlags(false, false) m.logger.Printf("[INFO] [update-leg] started (%s): window %s, no step starts at or after %s", trigger, window, sum.Deadline.In(o.Location).Format("15:04")) diff --git a/controller/internal/stacks/unattended_test.go b/controller/internal/stacks/unattended_test.go index ec1a8cc..4d61242 100644 --- a/controller/internal/stacks/unattended_test.go +++ b/controller/internal/stacks/unattended_test.go @@ -473,3 +473,23 @@ func TestR687_EmptyLegReportsEmptySteps(t *testing.T) { t.Fatalf("an empty leg must report steps as [], got %s", b) } } + +// R-705 (v0.279.0) — the MANUAL chain's leg runs by day: at 18:00 the night's W+5h (07:30) has passed, so the +// night leg starts nothing (window_end), while RunUpdateLegNow gives the leg its normal length from its own +// start and presses the step. COMPANION RED-PROOF: drop the `if manual` deadline in runUpdateLeg → the manual +// run also reports window_end and starts nothing. +func TestR705_TheManualLegRunsByDay(t *testing.T) { + day := time.Date(2026, 9, 25, 18, 0, 0, 0, time.UTC) + m, _, _, ups, _ := legManager(t) + legOpts(m, func(o *UpdateLegOptions) { o.Now = func() time.Time { return day } }) + if s := m.RunUpdateLeg(context.Background(), "night"); s == nil || s.Stopped != LegSkipWindowEnd || len(*ups) != 0 { + t.Fatalf("by day the NIGHT leg must start nothing: ups=%v summary=%+v", *ups, s) + } + s := m.RunUpdateLegNow(context.Background(), "manual-chain") + if s == nil || !s.Deadline.Equal(day.Add(195*time.Minute)) { + t.Fatalf("the manual leg's deadline %v, want %v", s.Deadline, day.Add(195*time.Minute)) + } + if len(*ups) == 0 || legStepFor(s, "nextcloud").Reason == LegSkipWindowEnd { + t.Fatalf("the manual leg pressed nothing by day: ups=%v summary=%+v", *ups, s) + } +} diff --git a/controller/internal/web/handler_debug.go b/controller/internal/web/handler_debug.go index 9bb6bb1..9167aba 100644 --- a/controller/internal/web/handler_debug.go +++ b/controller/internal/web/handler_debug.go @@ -75,6 +75,9 @@ func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) { // it. Only the debug route was absent, so this is IMPLEMENTED rather than deleted. case subpath == "backup/crossdrive" && r.Method == http.MethodPost: s.debugRunCrossDrive(w, r) + // R-705 (v0.279.0): the night's four legs, in order, now. + case subpath == "backup/night-chain" && r.Method == http.MethodPost: + s.debugRunNightChain(w, r) // R-397 — the button at debug.html:83 has posted here since it was added and NOTHING answered. // Verified 2026-08-30: this dispatch had no such case, so pressing „Restic integritás" did // nothing at all. Seventh instance of built-but-never-wired in this project; filed as R-400 in its diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index ce84913..7e39a23 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -460,6 +460,10 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri data["Meta"] = meta data["AppConfig"] = appCfg data["AlreadyDeployed"] = alreadyDeployed + // v0.279.0 (decision 45): the default login, before the install when nothing will replace it, after it + // while it is still in effect. + data["KnownLoginLine"] = s.knownLoginLine(lang, meta, appCfg, alreadyDeployed) + data["DefaultLoginReplaced"] = meta.AfterInstall != nil && !defaultLoginInEffect(meta, appCfg, alreadyDeployed) data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug) data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug) data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug) @@ -756,6 +760,9 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s data["Stack"] = found data["Meta"] = found.Meta data["AppInfo"] = found.Meta.AppInfo + // v0.279.0 (decision 45): the default-login card only while that login is in effect. + data["KnownLoginLine"] = s.knownLoginLine(s.langFor(r), &found.Meta, found.AppConfig, found.Deployed) + data["DefaultLoginReplaced"] = found.Meta.AfterInstall != nil && !defaultLoginInEffect(&found.Meta, found.AppConfig, found.Deployed) data["HasAppInfo"] = found.Meta.HasAppInfo() data["EffectiveSubdomain"] = effectiveSubdomain @@ -1124,6 +1131,7 @@ func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) { data := s.backupsCommonData("backups-apps", "Biztonsági mentés — Alkalmazások", r) data["TitleKey"] = "page.title.backups_apps" // i18n: the Hungarian title above is what hu renders s.backupsOffboxData(data, s.langFor(r)) // the tier-3 rows render $.Offbox status + data["HollowCopyLines"] = s.hollowCopyLines(s.langFor(r)) // Part D: running apps whose copy holds no data if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil { // Enrich AppDataInfo with storage labels @@ -1151,6 +1159,26 @@ func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) { s.executeTemplate(w, r, "backups_apps", data) } +// hollowCopyLines (Part D, v0.279.0) — one sentence per running app whose newest copy holds no data. +func (s *Server) hollowCopyLines(lang string) []string { + if s.backupMgr == nil { + return nil + } + var out []string + for _, h := range s.backupMgr.HollowCopies() { + name := h.App + if st, ok := s.stackMgr.GetStack(h.App); ok && st.Meta.DisplayName != "" { + name = st.Meta.DisplayName + } + key := "backups_apps.hollow_local" + if h.Tier == backup.HollowTierOffsite { + key = "backups_apps.hollow_offsite" + } + out = append(out, s.msgLang(lang, key, name)) + } + return out +} + // backupsRestoreHandler renders the Visszaállítás page: the restore panel, the offbox // restore-to-verify list and the .fab export/import loop. func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) { diff --git a/controller/internal/web/known_login.go b/controller/internal/web/known_login.go new file mode 100644 index 0000000..9d7d580 --- /dev/null +++ b/controller/internal/web/known_login.go @@ -0,0 +1,41 @@ +package web + +import ( + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// ── Known default logins (v0.279.0, `09` §3 decision 45) ──────────────────────────────────────────── +// +// "An app is never published with a login a stranger knows." Where the template's after_install replaces +// the default with a generated password, the default is gone once that command succeeded — the page must +// then NOT show it (it would send the household to a login that no longer works). Where it cannot (no +// after_install) or the command failed, the page and the install dialog say plainly what the default is and +// to change it at once. Pinned by internal/web/known_login_test.go. + +// defaultLoginInEffect: the template has a default login and nothing has replaced it on this install. +// installed=false is the install dialog, before the install: a declared after_install WILL replace it. +func defaultLoginInEffect(meta *stacks.Metadata, cfg *stacks.AppConfig, installed bool) bool { + if meta == nil || strings.TrimSpace(meta.AppInfo.DefaultCreds) == "" { + return false + } + if meta.AfterInstall == nil { + return true + } + if !installed { + return false + } + // Installed with an after_install: in effect only when the command FAILED (absent = not run yet — the + // deploy-done hook runs it within minutes; the page does not warn about a default it is replacing). + return cfg != nil && cfg.AfterInstall != nil && !cfg.AfterInstall.OK +} + +// knownLoginLine is the sentence, in lang, or "" when no default login is in effect. +func (s *Server) knownLoginLine(lang string, meta *stacks.Metadata, cfg *stacks.AppConfig, installed bool) string { + if !defaultLoginInEffect(meta, cfg, installed) { + return "" + } + creds := strings.ReplaceAll(meta.AppInfo.DefaultCreds, "DOMAIN", s.cfg.Customer.Domain) + return s.msgLang(lang, "app_info.known_login", creds) +} diff --git a/controller/internal/web/known_login_test.go b/controller/internal/web/known_login_test.go new file mode 100644 index 0000000..dd8f69b --- /dev/null +++ b/controller/internal/web/known_login_test.go @@ -0,0 +1,44 @@ +package web + +import ( + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// v0.279.0 (decision 45) — when the default login is IN EFFECT, and the sentence the household reads. +// COMPANION RED-PROOF: make defaultLoginInEffect ignore after_install (the pre-0.279.0 page: the default +// card always shown) → the "replaced" rows fail — the page would send the household to a dead login. +func TestKnownLogin_InEffectOnlyUntilReplaced(t *testing.T) { + withCreds := &stacks.Metadata{AppInfo: stacks.AppInfo{DefaultCreds: "admin / admin"}} + withFix := &stacks.Metadata{AppInfo: stacks.AppInfo{DefaultCreds: "admin@claper.co / claper"}, + AfterInstall: &stacks.AfterInstallCommand{Service: "claper", Command: []string{"x"}, Success: "OK"}} + ok := &stacks.AppConfig{AfterInstall: &stacks.AfterInstallRecord{OK: true}} + failed := &stacks.AppConfig{AfterInstall: &stacks.AfterInstallRecord{OK: false}} + for _, c := range []struct { + name string + meta *stacks.Metadata + cfg *stacks.AppConfig + installed bool + want bool + }{ + {"no default login", &stacks.Metadata{}, nil, false, false}, + {"default, nothing replaces it: before install", withCreds, nil, false, true}, + {"default, nothing replaces it: installed", withCreds, &stacks.AppConfig{}, true, true}, + {"after_install declared: before install", withFix, nil, false, false}, + {"after_install succeeded", withFix, ok, true, false}, + {"after_install not run yet", withFix, &stacks.AppConfig{}, true, false}, + {"after_install FAILED", withFix, failed, true, true}, + } { + if got := defaultLoginInEffect(c.meta, c.cfg, c.installed); got != c.want { + t.Errorf("%s: in effect = %v, want %v", c.name, got, c.want) + } + } + s := testServer(t) + if got, want := s.knownLoginLine("hu", withCreds, nil, false), "Ez az alkalmazás egy ismert, közös jelszóval indul: admin / admin. Telepítés után azonnal változtasd meg."; got != want { + t.Fatalf("hu sentence %q, want %q", got, want) + } + if got, want := s.knownLoginLine("en", withFix, failed, true), "This app starts with a known, shared password: admin@claper.co / claper. Change it right after the install."; got != want { + t.Fatalf("en sentence %q, want %q", got, want) + } +} diff --git a/controller/internal/web/night_chain.go b/controller/internal/web/night_chain.go new file mode 100644 index 0000000..cd2afa5 --- /dev/null +++ b/controller/internal/web/night_chain.go @@ -0,0 +1,110 @@ +package web + +import ( + "context" + "net/http" + "sync/atomic" + "time" +) + +// ── R-705 (v0.279.0): "run tonight's chain now" — a debug action ───────────────────────────────────── +// +// The night runs four legs in ONE order (07 §6.1, 09 §6.4.2): the database/volume dump at W, the +// second-drive copy at W+60m, the off-site copy at W+105m, and the automatic update leg chained after the +// off-site one. Until now the only way to see that chain by day was to move the backup window and wait +// two hours. This runs the same four legs, in the same order, one at a time, NOW. The whole-guest backup +// (the agent's) is not part of it — it has no controller trigger (R-705 keeps that half open). +// +// Refused while any backup/restore op or guarded update runs, and while a chain is already running. + +// nightChain is the four legs; each field is a seam so the order and the refusal are testable without +// Docker or restic. production: newNightChain(s). +type nightChain struct { + dump func(ctx context.Context) error + tier2 func() + offsite func(ctx context.Context) error // nil: no off-site target on this box + leg func(ctx context.Context) + busy func() (bool, string) + logf func(format string, args ...interface{}) +} + +var nightChainRunning atomic.Bool + +func (s *Server) newNightChain() nightChain { + c := nightChain{ + dump: s.backupMgr.RunDBDumps, + tier2: s.backupMgr.RunAllTier2, + leg: func(ctx context.Context) { s.stackMgr.RunUpdateLegNow(ctx, "manual-chain") }, + logf: s.logger.Printf, + busy: func() (bool, string) { + if s.backupMgr.IsRunning() || s.backupMgr.RestoreStatus().Running { + return true, "a backup or restore is running" + } + if s.stackMgr.AnyUpdating() { + return true, "a guarded update is running" + } + return false, "" + }, + } + if s.backupMgr.OffboxRunnable() { + c.offsite = s.backupMgr.RunOffboxBackup + } + return c +} + +// start refuses or launches; it reports which legs will run. +func (c nightChain) start() (bool, string, []string) { + if busy, why := c.busy(); busy { + return false, why, nil + } + if !nightChainRunning.CompareAndSwap(false, true) { + return false, "the night's chain is already running", nil + } + legs := []string{"db-dump", "tier2", "offsite", "update-leg"} + if c.offsite == nil { + legs = []string{"db-dump", "tier2", "update-leg"} + } + go c.run() + return true, "", legs +} + +func (c nightChain) run() { + defer nightChainRunning.Store(false) + ctx := context.Background() + t0 := time.Now() + step := func(name string, fn func() error) { + s := time.Now() + c.logf("[INFO] [night-chain] %s: started", name) + if err := fn(); err != nil { + c.logf("[WARN] [night-chain] %s: ended with an error after %s: %v — the chain goes on, as the night does", name, time.Since(s).Round(time.Second), err) + return + } + c.logf("[INFO] [night-chain] %s: done in %s", name, time.Since(s).Round(time.Second)) + } + c.logf("[INFO] [night-chain] manual run of tonight's chain: dump → second drive → off-site → update leg") + step("db-dump", func() error { return c.dump(ctx) }) + step("tier2", func() error { c.tier2(); return nil }) + if c.offsite != nil { + step("offsite", func() error { return c.offsite(ctx) }) + } else { + c.logf("[INFO] [night-chain] offsite: no off-site target on this box — skipped, as at night") + } + step("update-leg", func() error { c.leg(ctx); return nil }) + c.logf("[INFO] [night-chain] finished in %s", time.Since(t0).Round(time.Second)) +} + +func (s *Server) debugRunNightChain(w http.ResponseWriter, r *http.Request) { + if s.backupMgr == nil || s.stackMgr == nil { + writeDebugJSON(w, http.StatusBadRequest, false, "Backup manager nincs konfigurálva", nil) + return + } + s.backupMgr.MarkManualRun() + ok, why, legs := s.newNightChain().start() + if !ok { + s.logger.Printf("[WARN] [night-chain] manual run REFUSED: %s", why) + writeDebugJSON(w, http.StatusConflict, false, "refused: "+why, nil) + return + } + s.logger.Printf("[INFO] [night-chain] manual run started from %s: %v", r.RemoteAddr, legs) + writeDebugJSON(w, http.StatusAccepted, true, "started", map[string]interface{}{"legs": legs}) +} diff --git a/controller/internal/web/r705_night_chain_test.go b/controller/internal/web/r705_night_chain_test.go new file mode 100644 index 0000000..d9a68a8 --- /dev/null +++ b/controller/internal/web/r705_night_chain_test.go @@ -0,0 +1,77 @@ +package web + +import ( + "context" + "reflect" + "sync" + "testing" + "time" +) + +// R-705 (v0.279.0) — the manual night chain runs dump → Tier 2 → off-site → update leg, IN ORDER, one at a +// time; it is refused while anything else runs and while a chain runs; with no off-site target it skips +// only that leg. COMPANION RED-PROOF: swap two step calls in run() → the order assertion fails; drop the +// busy() check in start() → the refusal assertion fails. +func TestR705_NightChainRunsTheLegsInOrderAndRefusesWhenBusy(t *testing.T) { + var mu sync.Mutex + var order []string + rec := func(n string) { mu.Lock(); order = append(order, n); mu.Unlock() } + release := make(chan struct{}) + done := make(chan struct{}) + c := nightChain{ + dump: func(context.Context) error { rec("db-dump"); <-release; return nil }, + tier2: func() { rec("tier2") }, + offsite: func(context.Context) error { rec("offsite"); return nil }, + leg: func(context.Context) { rec("update-leg"); close(done) }, + busy: func() (bool, string) { return false, "" }, + logf: func(string, ...interface{}) {}, + } + ok, why, legs := c.start() + if !ok || !reflect.DeepEqual(legs, []string{"db-dump", "tier2", "offsite", "update-leg"}) { + t.Fatalf("start: ok=%v why=%q legs=%v", ok, why, legs) + } + // A second press while the first chain runs is refused. + if ok, why, _ := c.start(); ok || why != "the night's chain is already running" { + t.Fatalf("second start while running: ok=%v why=%q", ok, why) + } + close(release) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("the chain never reached the update leg") + } + for i := 0; i < 100 && nightChainRunning.Load(); i++ { + time.Sleep(10 * time.Millisecond) + } + if want := []string{"db-dump", "tier2", "offsite", "update-leg"}; !reflect.DeepEqual(order, want) { + t.Fatalf("order %v, want %v", order, want) + } + + // Busy: refused, nothing runs. + order = nil + c.busy = func() (bool, string) { return true, "a guarded update is running" } + if ok, why, _ := c.start(); ok || why != "a guarded update is running" { + t.Fatalf("busy start: ok=%v why=%q", ok, why) + } + time.Sleep(50 * time.Millisecond) + if len(order) != 0 { + t.Fatalf("a refused chain ran legs: %v", order) + } + + // No off-site target: that leg alone is skipped. + c.busy = func() (bool, string) { return false, "" } + c.offsite = nil + c.dump = func(context.Context) error { rec("db-dump"); return nil } + done2 := make(chan struct{}) + c.leg = func(context.Context) { rec("update-leg"); close(done2) } + if ok, _, legs := c.start(); !ok || !reflect.DeepEqual(legs, []string{"db-dump", "tier2", "update-leg"}) { + t.Fatalf("no-offsite start: ok=%v legs=%v", ok, legs) + } + <-done2 + for i := 0; i < 100 && nightChainRunning.Load(); i++ { + time.Sleep(10 * time.Millisecond) + } + if want := []string{"db-dump", "tier2", "update-leg"}; !reflect.DeepEqual(order, want) { + t.Fatalf("no-offsite order %v, want %v", order, want) + } +} diff --git a/controller/internal/web/r_partd_hollow_page_test.go b/controller/internal/web/r_partd_hollow_page_test.go new file mode 100644 index 0000000..fcf0709 --- /dev/null +++ b/controller/internal/web/r_partd_hollow_page_test.go @@ -0,0 +1,29 @@ +package web + +import ( + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" +) + +// Part D (v0.279.0) — the household sees the sentence on the REAL backup page (ServeHTTP → template), in +// Hungarian by default, and the page is silent when nothing is flagged (negative control). +// COMPANION RED-PROOF: drop `data["HollowCopyLines"]` from backupsAppsHandler → the sentence is absent. +func TestPartD_TheBackupPageSaysARunningAppHasNoData(t *testing.T) { + s := newDashboardServer(t, time.Time{}) + if s.backupMgr == nil { + t.Fatal("fixture has no backup manager") + } + quiet := getPage(t, s, "/backups/apps") + if quiet.Code != 200 || strings.Contains(quiet.Body.String(), "nem tartalmaz adatot") { + t.Fatalf("with nothing flagged the page must be silent (code %d)", quiet.Code) + } + s.backupMgr.FlagHollowCopyForTest("cloudapp", backup.HollowTierOffsite) + rec := getPage(t, s, "/backups/apps") + body := rec.Body.String() + if rec.Code != 200 || !strings.Contains(body, "A(z) cloudapp fut, de a legutóbbi távoli mentése nem tartalmaz adatot") { + t.Fatalf("the flagged app's sentence is not on the page (code %d)", rec.Code) + } +} diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 1d4c391..0f5a5f0 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -217,11 +217,11 @@ function appMigrate(btn,app,label){ {{end}} - {{if .AppInfo.DefaultCreds}} + {{if and .AppInfo.DefaultCreds (not .DefaultLoginReplaced)}}

{{T "app_info.alapertelmezett_belepes"}}

{{.AppInfo.DefaultCreds}}

-

{{T "app_info.az_elso_bejelentkezes_utan_azonnal_2"}}

+ {{if .KnownLoginLine}}

{{.KnownLoginLine}}

{{else}}

{{T "app_info.az_elso_bejelentkezes_utan_azonnal_2"}}

{{end}}
{{end}} diff --git a/controller/internal/web/templates/backups_apps.html b/controller/internal/web/templates/backups_apps.html index fe31365..1b4f143 100644 --- a/controller/internal/web/templates/backups_apps.html +++ b/controller/internal/web/templates/backups_apps.html @@ -8,6 +8,11 @@ {{template "backups_flash" .}} {{template "restore_banner" .}} +{{- if .HollowCopyLines}} +
+ {{range .HollowCopyLines}}
{{.}}
{{end}} +
+{{- end}} {{if not .Backup}} {{template "backups_empty" .}} diff --git a/controller/internal/web/templates/debug.html b/controller/internal/web/templates/debug.html index 1a61f4d..fc9fa5f 100644 --- a/controller/internal/web/templates/debug.html +++ b/controller/internal/web/templates/debug.html @@ -79,6 +79,8 @@ + + diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index 6dcc551..9a62806 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -25,6 +25,7 @@
{{if .FlashSuccess}}
{{.FlashSuccess}}
{{end}} {{if .FlashError}}
{{.FlashError}}
{{end}} + {{- if .KnownLoginLine}}
{{.KnownLoginLine}}
{{end}}
@@ -674,7 +675,7 @@