From 114ff2761aacf259ef1d42cd5a6561d17b6f1ede Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 5 Oct 2026 17:05:24 +0200 Subject: [PATCH] R-263 writer invariant pinned by an AST test; R-368 IsDefault comment exact (no binary change; burn-down) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 12 +++ REPORT.md | 37 ++------- .../r263_backup_target_writers_test.go | 75 +++++++++++++++++++ controller/internal/settings/settings.go | 7 +- 4 files changed, 96 insertions(+), 35 deletions(-) create mode 100644 controller/internal/settings/r263_backup_target_writers_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c60cd..dba2fd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## unreleased — a comment pinned by a test, a comment made exact; no binary change (burn-down 2026-10-05: R-263, R-368) + +The next release carries these two lines into its own entry. + +- **R-263:** `SetBackupTarget` is „the only writer that GRANTS" `StoragePath.BackupTarget` (ClearBackupTarget also writes + it, only `false`), now pinned by `internal/settings/r263_backup_target_writers_test.go`: an AST scan of every non-test + file under `internal/` and `cmd/` for an assignment or a composite-literal key, refusing anything but `false` outside + `SetBackupTarget`. Red-proofs (`ClearBackupTarget` writing `true`; a `BackupTarget: true` literal in `internal/web`) + both convict — `felhom.eu/documentation/audits/burndown-2026-10-05/r263-red-proof.txt`. +- **R-368:** `StoragePath.IsDefault`'s comment says who applies it — the deploy FORM pre-selects it; the deploy API + applies no default. Behaviour unchanged on purpose (it is right on the path households use). + ## v0.296.0 — a backup cut off by a power cut or a restart is said on the page; the whole-system backup text states today's measurement (R-519, R-518) (2026-10-05) **MinAgent: 0.131.0** (unchanged). New household string: `backups.interrupted_run` (Hungarian and English); the diff --git a/REPORT.md b/REPORT.md index 73bd605..9edfee4 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,35 +1,8 @@ -# REPORT — controller v0.296.0 (2026-10-05, late afternoon): a cut-off backup run is said; honest "Mentés most" wording +# REPORT — 2026-10-05 burn-down: a pinned invariant and an exact comment (no release) -Brief: the 2026-10-05 hub-safety brief (operator), Part E (R-518, R-519). Full session report: -`felhom.eu/REPORT-hub-safety-2026-10-05.md`. Architecture: `felhom.eu/documentation/architecture/07-backup-architecture.md` -(the restore-record paragraph and the R-518 lane-1 note), `09` decision 123. +Full session report: `felhom.eu/REPORT-burndown-2026-10-05.md`. Baseline `e563733` (v0.296.0). No binary change. -## Baseline and commits +- R-263 — `internal/settings/r263_backup_target_writers_test.go` (AST scan of internal/ + cmd/); two red-proofs convict. +- R-368 — `internal/settings/settings.go` `IsDefault` comment names the deploy form as the one that applies it. -- Baseline `477e2548db` (v0.295.0). Commit `ff69074` → image `gitea.dooplex.hu/admin/felhom-controller:0.296.0`. -- **MinAgent 0.131.0** (unchanged). Golden 0.296.0 baked + vouched (`felhom.eu/documentation/tests/golden-0.296.0-2026-10-05/`). -- Delivered by per-customer floors (demo-hp, demo-felhom, tester-1 at 0.296.0): both demo boxes ran 0.296.0 (healthy) - ~30 min later. - -## What changed - -- **R-519.** `internal/backup/run_record.go`: the app-data run is on record (`appdata-run.json`) while it runs; a start - that finds it running turns it into a notice on /backups and /backups/apps, kept until a run ends with every step OK. - After a restart the synthesised "last database backup" no longer reads OK over a cut run. Restore points were already - dated by their OLDEST part (v0.275.0). Wired in `main()` (`loadRunRecordAtStartup`, AST-pinned). -- **R-518.** The whole-system "Mentés most" text and confirm now state two measurements: ≈6 min / 9 apps (demo-hp - today: 09:19:08Z → last app back 09:24:55Z, local tier only) and ≈8 min / 12 apps (2026-09-14); "minutes, not seconds"; - longer when the off-site copy runs in the same run. - -## Tests - -`go build/vet/test ./...` green; controller gates green (golden-notice advisory before the bake). New: `TestRunRecord_*` -(3, incl. the production path through `runDBDumpsInternal`), `TestRunRecordAtStartup_FindsACutRun`, -`TestMainWiresRunRecord`, `TestR518_*` (both measurements, both pages and confirm), parity cases -`backups_interrupted_run`, `backups_apps_interrupted_run`; six parity fixtures re-captured for the changed copy (only the -copy lines differ). Red-proofs (4, all convict): `felhom.eu/documentation/audits/hub-safety-2026-10-05/partE/red-proof.txt`. - -## NOT yet live-validated - -- **The live cut on 9202** (restart the controller in the middle of a backup run, then read the page): the permission - check refused restarting the controller mid-backup. The operator is asked; R-519 stays narrowed until then. +`go build ./... && go vet ./... && go test ./...` green; `controller_gates.py --fast` green. diff --git a/controller/internal/settings/r263_backup_target_writers_test.go b/controller/internal/settings/r263_backup_target_writers_test.go new file mode 100644 index 0000000..5931027 --- /dev/null +++ b/controller/internal/settings/r263_backup_target_writers_test.go @@ -0,0 +1,75 @@ +package settings + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// R-263: "SetBackupTarget is the only writer that GRANTS StoragePath.BackupTarget" — a drive never acquires the +// whole-guest backup-target role by appearing (E-2 §3). This scans EVERY non-test Go file under controller/internal and +// cmd for a write of a field named BackupTarget — an assignment `x.BackupTarget = v` or a composite-literal key +// `BackupTarget: v` — and fails on any that is not the literal `false`, unless it sits inside SetBackupTarget. +// RED-PROOF: plant `s.StoragePaths[0].BackupTarget = true` in any other function → this test fails. +func TestR263_OnlySetBackupTargetGrantsTheRole(t *testing.T) { + var roots []string + for _, r := range []string{"..", "../../cmd"} { // internal/ (this package's parent) and cmd/ + if _, err := os.Stat(r); err == nil { + roots = append(roots, r) + } + } + files, writes := 0, 0 + var bad []string + for _, root := range roots { + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + fset := token.NewFileSet() + f, perr := parser.ParseFile(fset, path, nil, 0) + if perr != nil { + t.Fatalf("parse %s: %v", path, perr) + } + files++ + for _, decl := range f.Decls { + fn, _ := decl.(*ast.FuncDecl) + inSetter := fn != nil && fn.Name.Name == "SetBackupTarget" + ast.Inspect(decl, func(n ast.Node) bool { + check := func(val ast.Expr, pos token.Pos) { + writes++ + if id, ok := val.(*ast.Ident); ok && id.Name == "false" { + return + } + if !inSetter { + bad = append(bad, fset.Position(pos).String()) + } + } + switch x := n.(type) { + case *ast.AssignStmt: + for i, lhs := range x.Lhs { + if sel, ok := lhs.(*ast.SelectorExpr); ok && sel.Sel.Name == "BackupTarget" && i < len(x.Rhs) { + check(x.Rhs[i], x.Pos()) + } + } + case *ast.KeyValueExpr: + if k, ok := x.Key.(*ast.Ident); ok && k.Name == "BackupTarget" { + check(x.Value, x.Pos()) + } + } + return true + }) + } + return nil + }) + } + if files < 100 || writes < 2 { + t.Fatalf("scan too small to mean anything: %d files, %d BackupTarget writes (want the two in settings.go)", files, writes) + } + if len(bad) > 0 { + t.Fatalf("BackupTarget may be granted outside SetBackupTarget at: %v", bad) + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index be31108..f364205 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -569,7 +569,7 @@ const NetworkMountRoot = "/mnt/felhom-drives" type StoragePath struct { Path string `json:"path"` // e.g., "/mnt/hdd_1" (drive) or "/mnt/felhom-drives/" (network) Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB" - IsDefault bool `json:"is_default,omitempty"` // new apps use this by default + IsDefault bool `json:"is_default,omitempty"` // the deploy FORM pre-selects it (templates/deploy.html); the deploy API applies no default (R-368) Schedulable bool `json:"schedulable"` // whether new apps can be deployed here AddedAt string `json:"added_at"` // RFC3339 Disconnected bool `json:"disconnected,omitempty"` // true when drive detected as disconnected @@ -1652,8 +1652,9 @@ func (s *Settings) SetSchedulable(path string, schedulable bool) error { } // SetBackupTarget assigns the whole-guest backup-target role to exactly one storage path, clearing it -// from every other. This is the ONLY writer of StoragePath.BackupTarget — registration must never set -// it (E-2 §3: a drive never acquires a role by appearing). +// from every other. This is the ONLY writer that GRANTS StoragePath.BackupTarget (ClearBackupTarget also writes +// the field, only ever false) — registration must never set it (E-2 §3: a drive never acquires a role by +// appearing). Pinned by TestR263_OnlySetBackupTargetGrantsTheRole (a scan of every non-test file). // // Refusals, both structural rather than advisory: // - a NETWORK share can never be the target. vzdump writes a multi-GB archive through the host, and