v0.279.0: after_install (decision 45), known default logins on the page, Part D empty-backup alarm, night chain (R-705), R-706
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
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:
@@ -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)
|
## 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:
|
**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: none. Evidence:
|
||||||
|
|||||||
+6
-1
@@ -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-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 —
|
> **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`
|
> off-site only when NEWER or the only copy), `KeptOffsiteCopies` (ONE `snapshots --json`, 20 s bound; `offsiteNewest`
|
||||||
|
|||||||
@@ -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) |
|
| `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 |
|
| `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 `<hdd>/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.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 `<hdd>/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) |
|
| `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` |
|
| `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` |
|
||||||
|
|
||||||
|
|||||||
@@ -1916,6 +1916,10 @@ that folder is never a dead end, and an install never runs into it silently (R-6
|
|||||||
- **Where it lives.** `<drive>/kept/` is beside `appdata/` and `userdata/`, inside neither: no app bind, FileBrowser
|
- **Where it lives.** `<drive>/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`.
|
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`).
|
- **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
|
- **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.
|
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
|
- **R-690 (fixed here).** The removed-app restore (R-487) never found a unit on a DATA drive — it asked
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1191,6 +1191,18 @@ func main() {
|
|||||||
// It rides the EXISTING per-run digest (backup_run_failures) rather than a new event type: a
|
// 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
|
// 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".
|
// 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) {
|
backupMgr.SetOffboxGapNotify(func(gaps map[string][]string) {
|
||||||
apps := make([]string, 0, len(gaps))
|
apps := make([]string, 0, len(gaps))
|
||||||
for app := range 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
|
// 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
|
// 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.
|
// `app_deploy_started`, emitted by the API router beside its 202.
|
||||||
if notifier != nil {
|
// v0.279.0: the hook is set on EVERY box — after_install (decision 45) must run on a box with no hub too;
|
||||||
stackMgr.SetDeployDoneHook(func(name string, ok bool, detail string) {
|
// only the events need the notifier.
|
||||||
display := name
|
stackMgr.SetDeployDoneHook(func(name string, ok bool, detail string) {
|
||||||
if s, found := stackMgr.GetStack(name); found && s.Meta.DisplayName != "" {
|
display := name
|
||||||
display = s.Meta.DisplayName
|
if s, found := stackMgr.GetStack(name); found && s.Meta.DisplayName != "" {
|
||||||
}
|
display = s.Meta.DisplayName
|
||||||
if ok {
|
}
|
||||||
|
if ok {
|
||||||
|
if notifier != nil {
|
||||||
notifier.NotifyAppDeployed(name, display)
|
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)
|
notifier.NotifyAppDeployFailed(name, display, detail)
|
||||||
})
|
}
|
||||||
}
|
})
|
||||||
// R-681 (v0.270.0): an install a restart cut off is finished through the failure path and reported —
|
// 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.
|
// AFTER the deploy-done hook exists, so its event is sent.
|
||||||
if names := stackMgr.RecoverInterruptedInstalls(); len(names) > 0 {
|
if names := stackMgr.RecoverInterruptedInstalls(); len(names) > 0 {
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<app>`, 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) {
|
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack 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 {
|
if body.RemoveBackups && r.backupMgr != nil && len(mirrorDirs) > 0 {
|
||||||
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveTier2Mirrors(name, mirrorDirs)...)
|
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
|
// 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
|
// tier2RecordedCopyDir needs — are forgotten ONLY when the customer asked for the backups to be
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ type Manager struct {
|
|||||||
// INIT/TEST ONLY.
|
// INIT/TEST ONLY.
|
||||||
keptUnitRestoreFn func(app, unitDir string) (UnitRestoreResult, error)
|
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
|
// 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).
|
// without Docker. Default to the real DiscoverDatabases / ImportDump (lazy-init in reimportDBDumps).
|
||||||
discoverDBs func(ctx context.Context) ([]DiscoveredDB, error)
|
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:
|
// 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).
|
// the capture folds the stamps the legs above just wrote (v0.275.0).
|
||||||
m.captureAllRecoveryUnits(true)
|
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
|
// F5 (CAMPAIGN-3): after the units are fresh on the CURRENT drives, prune any orphaned
|
||||||
// backups/primary/<app> dir an app left on an OLD drive when its HDD_PATH moved — pure disk
|
// backups/primary/<app> dir an app left on an OLD drive when its HDD_PATH moved — pure disk
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
}
|
||||||
@@ -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
|
// 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
|
// 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.
|
// 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) {
|
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",
|
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))
|
stack, src, len(extra))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ const (
|
|||||||
// gate — one constant, so the two can never disagree (TestLegDeadlineAndGateShareW5h).
|
// gate — one constant, so the two can never disagree (TestLegDeadlineAndGateShareW5h).
|
||||||
const UpdateLegStopOffsetMin = 300
|
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
|
// 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
|
// hour:minute — the same contract as the scheduler's parseDailyTime, kept here so this package is
|
||||||
// dependency-free and reusable by the quiesce gate.
|
// dependency-free and reusable by the quiesce gate.
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
"app_import.tarolo": "Storage",
|
"app_import.tarolo": "Storage",
|
||||||
"app_import.titkos": "Encrypted",
|
"app_import.titkos": "Encrypted",
|
||||||
"app_import.visszaallitas_inditasa": "Start restore",
|
"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_kezdeti_jelszo_beolvasasa_nem": "The first password could not be read.",
|
||||||
"app_info.a_regi_adatok_erintetlenek": "The old data is untouched.",
|
"app_info.a_regi_adatok_erintetlenek": "The old data is untouched.",
|
||||||
"app_info.adatait_ide": "data to:",
|
"app_info.adatait_ide": "data to:",
|
||||||
@@ -233,6 +234,8 @@
|
|||||||
"backups.utolso_sikeres_mentes": "✓ Last successful backup: {{fmtTime .SuccessAt}} ({{timeAgo .SuccessAt}})",
|
"backups.utolso_sikeres_mentes": "✓ Last successful backup: {{fmtTime .SuccessAt}} ({{timeAgo .SuccessAt}})",
|
||||||
"backups.utolso_teljes_mentes": "Last full backup",
|
"backups.utolso_teljes_mentes": "Last full backup",
|
||||||
"backups.visszaallitas_ellenorizve": "Restore checked",
|
"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": "Backup 1",
|
||||||
"backups_apps.1_mentes_auto": "Backup 1 auto",
|
"backups_apps.1_mentes_auto": "Backup 1 auto",
|
||||||
"backups_apps.2_mentes": "Backup 2",
|
"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.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.consequence.kept": "This is where your files are kept. It is backed up.",
|
||||||
"datapath.free_space": "%.1f GB free",
|
"datapath.free_space": "%.1f GB free",
|
||||||
|
"debug.night_chain": "Tonight's chain now (dump → second drive → off-site → update)",
|
||||||
"debug.aktiv": "Active",
|
"debug.aktiv": "Active",
|
||||||
"debug.aktiv_feladat": "Active job",
|
"debug.aktiv_feladat": "Active job",
|
||||||
"debug.alkalmazas": "App",
|
"debug.alkalmazas": "App",
|
||||||
|
|||||||
@@ -107,6 +107,7 @@
|
|||||||
"app_import.tarolo": "Tároló",
|
"app_import.tarolo": "Tároló",
|
||||||
"app_import.titkos": "Titkos",
|
"app_import.titkos": "Titkos",
|
||||||
"app_import.visszaallitas_inditasa": "Visszaállítás indítása",
|
"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_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.a_regi_adatok_erintetlenek": "A régi adatok érintetlenek.",
|
||||||
"app_info.adatait_ide": "adatait ide:",
|
"app_info.adatait_ide": "adatait ide:",
|
||||||
@@ -229,6 +230,8 @@
|
|||||||
"backups.utolso_sikeres_mentes": "✓ Utolsó sikeres mentés: {{fmtTime .SuccessAt}} ({{timeAgo .SuccessAt}})",
|
"backups.utolso_sikeres_mentes": "✓ Utolsó sikeres mentés: {{fmtTime .SuccessAt}} ({{timeAgo .SuccessAt}})",
|
||||||
"backups.utolso_teljes_mentes": "Utolsó teljes mentés",
|
"backups.utolso_teljes_mentes": "Utolsó teljes mentés",
|
||||||
"backups.visszaallitas_ellenorizve": "Visszaállítás ellenőrizve",
|
"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": "1. mentés",
|
||||||
"backups_apps.1_mentes_auto": "1. mentés auto",
|
"backups_apps.1_mentes_auto": "1. mentés auto",
|
||||||
"backups_apps.2_mentes": "2. mentés",
|
"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.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.consequence.kept": "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla.",
|
||||||
"datapath.free_space": "%.1f GB szabad",
|
"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": "Aktív",
|
||||||
"debug.aktiv_feladat": "Aktív feladat",
|
"debug.aktiv_feladat": "Aktív feladat",
|
||||||
"debug.alkalmazas": "Alkalmazás",
|
"debug.alkalmazas": "Alkalmazás",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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).
|
// 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"`
|
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.
|
// InstalledImage is one compose service's observed image. See AppConfig.InstalledImages.
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) {
|
|||||||
cfg.FailedStep = prior.FailedStep
|
cfg.FailedStep = prior.FailedStep
|
||||||
cfg.LastUpdateUndone = prior.LastUpdateUndone
|
cfg.LastUpdateUndone = prior.LastUpdateUndone
|
||||||
cfg.LastAutoUpdate = prior.LastAutoUpdate
|
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 {
|
if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 {
|
||||||
cur := ""
|
cur := ""
|
||||||
if prior.ConversionCopy != nil {
|
if prior.ConversionCopy != nil {
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// the drive and invisible until `occ files:scan --all`. Optional; absent = nothing runs.
|
||||||
AfterLoad *AfterLoadCommand `yaml:"after_load,omitempty" json:"after_load,omitempty"`
|
AfterLoad *AfterLoadCommand `yaml:"after_load,omitempty" json:"after_load,omitempty"`
|
||||||
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,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
|
// 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
|
// 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.
|
// surfaces it on the app page, so the customer never has to dig through logs. Optional.
|
||||||
|
|||||||
@@ -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
|
// 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").
|
// is not wired). trigger names the caller for the log ("after-offsite").
|
||||||
func (m *Manager) RunUpdateLeg(ctx context.Context, trigger string) *UpdateLegSummary {
|
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() {
|
if !m.leg.run.TryLock() {
|
||||||
m.logger.Printf("[WARN] [update-leg] a leg is already running — this call (%s) does nothing", trigger)
|
m.logger.Printf("[WARN] [update-leg] a leg is already running — this call (%s) does nothing", trigger)
|
||||||
return nil
|
return nil
|
||||||
@@ -249,6 +260,9 @@ func (m *Manager) RunUpdateLeg(ctx context.Context, trigger string) *UpdateLegSu
|
|||||||
window = o.WindowStart()
|
window = o.WindowStart()
|
||||||
}
|
}
|
||||||
sum.Deadline = LegDeadline(sum.StartedAt, window, o.Location)
|
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)
|
m.setLegFlags(true, false)
|
||||||
defer m.setLegFlags(false, 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"))
|
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"))
|
||||||
|
|||||||
@@ -473,3 +473,23 @@ func TestR687_EmptyLegReportsEmptySteps(t *testing.T) {
|
|||||||
t.Fatalf("an empty leg must report steps as [], got %s", b)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
// it. Only the debug route was absent, so this is IMPLEMENTED rather than deleted.
|
||||||
case subpath == "backup/crossdrive" && r.Method == http.MethodPost:
|
case subpath == "backup/crossdrive" && r.Method == http.MethodPost:
|
||||||
s.debugRunCrossDrive(w, r)
|
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.
|
// 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
|
// 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
|
// nothing at all. Seventh instance of built-but-never-wired in this project; filed as R-400 in its
|
||||||
|
|||||||
@@ -460,6 +460,10 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
|||||||
data["Meta"] = meta
|
data["Meta"] = meta
|
||||||
data["AppConfig"] = appCfg
|
data["AppConfig"] = appCfg
|
||||||
data["AlreadyDeployed"] = alreadyDeployed
|
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["LogoURL"] = s.cfg.AppLogoURL(meta.Slug)
|
||||||
data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug)
|
data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug)
|
||||||
data["AppPageURL"] = s.cfg.AppPageURL(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["Stack"] = found
|
||||||
data["Meta"] = found.Meta
|
data["Meta"] = found.Meta
|
||||||
data["AppInfo"] = found.Meta.AppInfo
|
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["HasAppInfo"] = found.Meta.HasAppInfo()
|
||||||
data["EffectiveSubdomain"] = effectiveSubdomain
|
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 := 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
|
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
|
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 {
|
if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil {
|
||||||
// Enrich AppDataInfo with storage labels
|
// 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)
|
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
|
// backupsRestoreHandler renders the Visszaállítás page: the restore panel, the offbox
|
||||||
// restore-to-verify list and the .fab export/import loop.
|
// restore-to-verify list and the .fab export/import loop.
|
||||||
func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,11 +217,11 @@ function appMigrate(btn,app,label){
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .AppInfo.DefaultCreds}}
|
{{if and .AppInfo.DefaultCreds (not .DefaultLoginReplaced)}}
|
||||||
<div class="app-info-card">
|
<div class="app-info-card">
|
||||||
<h3>{{T "app_info.alapertelmezett_belepes"}}</h3>
|
<h3>{{T "app_info.alapertelmezett_belepes"}}</h3>
|
||||||
<p class="app-info-creds">{{.AppInfo.DefaultCreds}}</p>
|
<p class="app-info-creds">{{.AppInfo.DefaultCreds}}</p>
|
||||||
<p class="app-info-creds-warn">{{T "app_info.az_elso_bejelentkezes_utan_azonnal_2"}}</p>
|
{{if .KnownLoginLine}}<p class="app-info-creds-warn" id="known-login-line">{{.KnownLoginLine}}</p>{{else}}<p class="app-info-creds-warn">{{T "app_info.az_elso_bejelentkezes_utan_azonnal_2"}}</p>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
|
|
||||||
{{template "backups_flash" .}}
|
{{template "backups_flash" .}}
|
||||||
{{template "restore_banner" .}}
|
{{template "restore_banner" .}}
|
||||||
|
{{- if .HollowCopyLines}}
|
||||||
|
<div class="alert alert-error" style="margin-bottom:1.5rem">
|
||||||
|
{{range .HollowCopyLines}}<div>{{.}}</div>{{end}}
|
||||||
|
</div>
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
{{if not .Backup}}
|
{{if not .Backup}}
|
||||||
{{template "backups_empty" .}}
|
{{template "backups_empty" .}}
|
||||||
|
|||||||
@@ -79,6 +79,8 @@
|
|||||||
|
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-crossdrive" data-label="{{T "debug.csak_cross_drive"}}" onclick="triggerAction('btn-crossdrive','/api/debug/backup/crossdrive','POST')">{{T "debug.csak_cross_drive"}}</button>
|
<button class="btn btn-secondary btn-sm" id="btn-crossdrive" data-label="{{T "debug.csak_cross_drive"}}" onclick="triggerAction('btn-crossdrive','/api/debug/backup/crossdrive','POST')">{{T "debug.csak_cross_drive"}}</button>
|
||||||
<span class="debug-result" id="btn-crossdrive-result"></span>
|
<span class="debug-result" id="btn-crossdrive-result"></span>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-nightchain" data-label="{{T "debug.night_chain"}}" onclick="triggerAction('btn-nightchain','/api/debug/backup/night-chain','POST')">{{T "debug.night_chain"}}</button>
|
||||||
|
<span class="debug-result" id="btn-nightchain-result"></span>
|
||||||
|
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-integrity" data-label="{{T "debug.restic_integritas"}}" onclick="triggerAction('btn-integrity','/api/debug/backup/integrity','POST')">{{T "debug.restic_integritas"}}</button>
|
<button class="btn btn-secondary btn-sm" id="btn-integrity" data-label="{{T "debug.restic_integritas"}}" onclick="triggerAction('btn-integrity','/api/debug/backup/integrity','POST')">{{T "debug.restic_integritas"}}</button>
|
||||||
<span class="debug-result" id="btn-integrity-result"></span>
|
<span class="debug-result" id="btn-integrity-result"></span>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<div class="deploy-container">
|
<div class="deploy-container">
|
||||||
{{if .FlashSuccess}}<div class="flash flash-success">{{.FlashSuccess}}</div>{{end}}
|
{{if .FlashSuccess}}<div class="flash flash-success">{{.FlashSuccess}}</div>{{end}}
|
||||||
{{if .FlashError}}<div class="flash flash-error">{{.FlashError}}</div>{{end}}
|
{{if .FlashError}}<div class="flash flash-error">{{.FlashError}}</div>{{end}}
|
||||||
|
{{- if .KnownLoginLine}}<div class="flash flash-error" id="known-login-line">{{.KnownLoginLine}}</div>{{end}}
|
||||||
<div class="deploy-info">
|
<div class="deploy-info">
|
||||||
<img class="deploy-logo" src="{{.LogoURL}}" alt="" data-fallback="/static/app-placeholder.svg"
|
<img class="deploy-logo" src="{{.LogoURL}}" alt="" data-fallback="/static/app-placeholder.svg"
|
||||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{.LogoPNGURL}}';}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';}">
|
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{.LogoPNGURL}}';}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';}">
|
||||||
@@ -674,7 +675,7 @@
|
|||||||
<script>
|
<script>
|
||||||
var postDeployInfo = {
|
var postDeployInfo = {
|
||||||
firstSteps: {{json .Meta.AppInfo.FirstSteps}},
|
firstSteps: {{json .Meta.AppInfo.FirstSteps}},
|
||||||
defaultCreds: {{json .Meta.AppInfo.DefaultCreds}},
|
defaultCreds: {{if .Meta.AfterInstall}}""{{else}}{{json .Meta.AppInfo.DefaultCreds}}{{end}},
|
||||||
docsURL: {{json .Meta.AppInfo.DocsURL}},
|
docsURL: {{json .Meta.AppInfo.DocsURL}},
|
||||||
domain: {{json .Domain}},
|
domain: {{json .Domain}},
|
||||||
displayName: {{json .Meta.DisplayName}},
|
displayName: {{json .Meta.DisplayName}},
|
||||||
|
|||||||
@@ -245,6 +245,8 @@
|
|||||||
|
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-crossdrive" data-label="Csak cross-drive" onclick="triggerAction('btn-crossdrive','/api/debug/backup/crossdrive','POST')">Csak cross-drive</button>
|
<button class="btn btn-secondary btn-sm" id="btn-crossdrive" data-label="Csak cross-drive" onclick="triggerAction('btn-crossdrive','/api/debug/backup/crossdrive','POST')">Csak cross-drive</button>
|
||||||
<span class="debug-result" id="btn-crossdrive-result"></span>
|
<span class="debug-result" id="btn-crossdrive-result"></span>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-nightchain" data-label="Az éjszakai lánc most (mentés → 2. meghajtó → távoli → frissítés)" onclick="triggerAction('btn-nightchain','/api/debug/backup/night-chain','POST')">Az éjszakai lánc most (mentés → 2. meghajtó → távoli → frissítés)</button>
|
||||||
|
<span class="debug-result" id="btn-nightchain-result"></span>
|
||||||
|
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-integrity" data-label="Restic integritás" onclick="triggerAction('btn-integrity','/api/debug/backup/integrity','POST')">Restic integritás</button>
|
<button class="btn btn-secondary btn-sm" id="btn-integrity" data-label="Restic integritás" onclick="triggerAction('btn-integrity','/api/debug/backup/integrity','POST')">Restic integritás</button>
|
||||||
<span class="debug-result" id="btn-integrity-result"></span>
|
<span class="debug-result" id="btn-integrity-result"></span>
|
||||||
|
|||||||
@@ -91,6 +91,9 @@
|
|||||||
"kept.backup.none": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.",
|
"kept.backup.none": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.",
|
||||||
"kept.backup.own": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.",
|
"kept.backup.own": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.",
|
||||||
"kept.backup.second": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.",
|
"kept.backup.second": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.",
|
||||||
|
"app_info.known_login": "BORN AS A KEY, v0.279.0 (09 decision 45 / Part D) -- a NEW sentence, never a Go literal. Pinned by internal/web/known_login_test.go / internal/web/r_partd_hollow_page_test.go.",
|
||||||
|
"backups_apps.hollow_local": "BORN AS A KEY, v0.279.0 (09 decision 45 / Part D) -- a NEW sentence, never a Go literal. Pinned by internal/web/known_login_test.go / internal/web/r_partd_hollow_page_test.go.",
|
||||||
|
"backups_apps.hollow_offsite": "BORN AS A KEY, v0.279.0 (09 decision 45 / Part D) -- a NEW sentence, never a Go literal. Pinned by internal/web/known_login_test.go / internal/web/r_partd_hollow_page_test.go.",
|
||||||
"err.kept.offsite_not_usable": "BORN AS A KEY, R-691 (2) (v0.277.0) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go TestR691_InstallChoiceNamesTheOffsiteCopy / internal/backup/r691_kept_offsite_test.go.",
|
"err.kept.offsite_not_usable": "BORN AS A KEY, R-691 (2) (v0.277.0) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go TestR691_InstallChoiceNamesTheOffsiteCopy / internal/backup/r691_kept_offsite_test.go.",
|
||||||
"err.kept.offsite_version_unknown": "BORN AS A KEY, R-691 (2) (v0.277.0) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go TestR691_InstallChoiceNamesTheOffsiteCopy / internal/backup/r691_kept_offsite_test.go.",
|
"err.kept.offsite_version_unknown": "BORN AS A KEY, R-691 (2) (v0.277.0) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go TestR691_InstallChoiceNamesTheOffsiteCopy / internal/backup/r691_kept_offsite_test.go.",
|
||||||
"kept.backup.offsite": "BORN AS A KEY, R-691 (2) (v0.277.0) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go TestR691_InstallChoiceNamesTheOffsiteCopy / internal/backup/r691_kept_offsite_test.go.",
|
"kept.backup.offsite": "BORN AS A KEY, R-691 (2) (v0.277.0) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go TestR691_InstallChoiceNamesTheOffsiteCopy / internal/backup/r691_kept_offsite_test.go.",
|
||||||
|
|||||||
Reference in New Issue
Block a user