From fc796dd95e0eb3176d43ad49ae7538fa58ceb714 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 4 Oct 2026 10:56:19 +0200 Subject: [PATCH] =?UTF-8?q?v0.291.0:=20decision=2078=20(R-726)=20=E2=80=94?= =?UTF-8?q?=20a=20returning=20household's=20first=20night=20sets=20the=20o?= =?UTF-8?q?rphaned=20copy=20aside;=20R-838=20=E2=80=94=20traefik=20v3.7.13?= =?UTF-8?q?,=20cloudflared=202026.9.3,=20filebrowser=201.5.6-stable,=20a?= =?UTF-8?q?=20release=20now=20moves=20a=20running=20file=20browser,=20chec?= =?UTF-8?q?k-infra-pins.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 23 +++++ REPORT.md | 14 ++-- controller/internal/backup/offbox.go | 20 +++-- .../internal/backup/offbox_orphan_test.go | 75 ++++++++++++++++- controller/internal/infra/infra.go | 6 +- controller/internal/stacks/infra.go | 27 +++++- .../internal/stacks/infra_tunnel_test.go | 27 ++++++ controller/scripts/check-infra-pins.py | 84 +++++++++++++++++++ 8 files changed, 258 insertions(+), 18 deletions(-) create mode 100755 controller/scripts/check-infra-pins.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c3340..b5baa46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +## v0.291.0 — a returning household's first night makes an off-site copy (decision 78, R-726); the built-in images raised and a release now moves them (R-838) (2026-10-04) + +**MinAgent: 0.131.0** (unchanged). No new household string. + +- **R-726 — decision 78.** A CLAIMED box whose off-site repository is orphaned and that has NEVER made an off-site copy + itself (`LastSuccess` empty — a returning household's new box, night one) now sets the old copy aside by itself and + starts a new one, exactly as an unclaimed box already did. Nothing is deleted; the moved copy is recorded + (`OrphanedRenamedTo`) and can be put back. A claimed box that HAS made copies still shows the orphan card and waits + for the household (its key changing is a real fault). Tests `TestR726_ReturningHouseholdFirstNightSetsAside`, + `TestR726_NotOrphanedChangesNothing`; `TestOffbox_OrphanDetection_Claimed` now pins the "has made copies" case. + Red-proved both ways. +- **R-838 — the infrastructure images.** `traefik:v3.6.7 → v3.7.13`, `cloudflare/cloudflared:2026.6.0 → 2026.9.3`, + `gtstef/filebrowser:1.3.3-stable → 1.5.6-stable` (release notes read: nothing we use is removed — filebrowser drops + `source.config.disableIndexing` and adds auth rate limiting; traefik 3.7 tightens StripPrefix, BasicAuth and `Host(*)`, + none of which we configure; cloudflared deprecates `--transport-loglevel`, which we do not pass). +- **A release now moves a running file browser.** traefik and cloudflared were already recreated when their rendered + file changed (the image line is in it). The file browser was not: its bring-up skipped a running container and its + compose is rewritten only when storage changes, so a raised pin never reached an installed box. A running file + browser whose compose names another image now gets ONLY the `image:` line replaced (the mounts stay) and is + recreated once. `TestReconcileFileBrowserImage_MovesOnlyTheImage`, red-proved. +- **`scripts/check-infra-pins.py`** — the monthly re-test's infrastructure half: newest upstream release per pin, in the + same channel; BEHIND exits 1 (report only). + ## v0.290.0 — the clean-up guard lets an honest window through (R-824); a due set-aside deletion goes to the hub (decision 74, R-823) (2026-10-04) **MinAgent: 0.131.0** (unchanged). **Needs hub v0.128.0** (`abandon-request` / `-status` / `-cancel`; the window cap of diff --git a/REPORT.md b/REPORT.md index 8f03706..62f524a 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,10 +1,8 @@ -# REPORT — v0.290.0: the clean-up guard lets honest windows through; set-aside deletion via the hub — 2026-10-04 +# REPORT — v0.291.0: decision 78 (R-726) and the infrastructure images (R-838) — 2026-10-04 -Full session report: `felhom.eu/REPORT-offsite-finish-2026-10-04.md`. Decisions 68–74 (`09` §3). +Full session report: `felhom.eu/REPORT-os-guest-lane-2026-10-04.md`. -- **R-824:** a young snapshot superseded the same day is excluded (kept for a later window), not a refusal; a plan above - the hub's weekly cap refuses (R-833 records the cost). Live: window 2 on demo-hp, no refusal, 127→127. -- **R-823 / decision 74:** a due abandonment is handed to the hub (7-day wait, cancellable); a recovery cancels at the hub; - the page keeps the hub's date. Live on tester-1 via the hub. -- Red-proofs: `felhom.eu/documentation/audits/offsite-finish-2026-10-04/red-proofs-controller.txt`. -- Both demo boxes on 0.290.0; floor 0.290.0 served; golden 0.290.0 baked and vouched. **MinAgent 0.131.0** (unchanged). +- **R-726 (decision 78):** a claimed box that never made an off-site copy sets an orphaned old copy aside on its first + night and starts a new one; nothing deleted. Red-proved both ways. +- **R-838:** traefik v3.7.13, cloudflared 2026.9.3, filebrowser 1.5.6-stable; a running file browser is now moved by a + release (only its image line changes). `scripts/check-infra-pins.py` for the monthly re-test. diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 72b32a0..14feb4e 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -875,14 +875,24 @@ func (m *Manager) ensureOffboxRepo(ctx context.Context, base, env []string) erro return nil case "orphaned": // The repo EXISTS but is keyed under a passphrase we no longer have (the reinstall shape). An - // UNCLAIMED (as-delivered) box auto-resets (Scenario B); a CLAIMED box surfaces the orphan card - // and skips until the customer confirms a reset (Scenario C). Move-aside, never delete. - if !m.settings.GetClaimed() { - m.logger.Printf("[INFO] [offbox] orphaned repo on an UNCLAIMED box — auto-resetting (move-aside + re-init)") + // UNCLAIMED (as-delivered) box auto-resets (Scenario B). So does a CLAIMED box that has NEVER made + // an off-site copy itself (LastSuccess empty) — a returning household's new box on its first night + // (`09` §3 decision 78, R-726): without this it would make no off-site copy until someone pressed + // the reset button. A claimed box that HAS made copies surfaces the orphan card and skips until the + // customer confirms (Scenario C) — its key changing is a real fault, not a new box. Move-aside, never + // delete; the old copy can be put back. Pinned by TestOffbox_OrphanDetection_* and TestR726_*. + t0 := m.settings.GetOffboxTarget() + firstNight := t0 != nil && t0.LastSuccess == "" + if !m.settings.GetClaimed() || firstNight { + reason := "auto (unclaimed)" + if m.settings.GetClaimed() { + reason = "auto (returning household — this box never made an off-site copy; decision 78)" + } + m.logger.Printf("[INFO] [offbox] orphaned repo, %s — setting the old copy aside (move-aside + re-init, nothing deleted)", reason) if m.offboxOrphanEvent != nil { m.offboxOrphanEvent("offbox_repo_orphaned", "") } - if rerr := m.resetOrphanedRepo(ctx, base, env, "auto (unclaimed)"); rerr != nil { + if rerr := m.resetOrphanedRepo(ctx, base, env, reason); rerr != nil { m.markOrphaned() // auto-reset failed → fall back to the orphan card so it isn't silent return ErrOffboxOrphaned } diff --git a/controller/internal/backup/offbox_orphan_test.go b/controller/internal/backup/offbox_orphan_test.go index d140f92..46dacaf 100644 --- a/controller/internal/backup/offbox_orphan_test.go +++ b/controller/internal/backup/offbox_orphan_test.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // classifyResticProbe maps the exact restic stderr to a repo class (the 2026-07-17 diagnosis @@ -59,7 +61,10 @@ func wrongPwRunner(seen *[]string) offboxRunner { // no state → these assertions FAIL. func TestOffbox_OrphanDetection_Claimed(t *testing.T) { m, sett := newOffboxManager(t) - if err := sett.SetClaimed(); err != nil { // claimed → orphan card, NEVER auto-reset + if err := sett.SetClaimed(); err != nil { // claimed AND has made copies before → orphan card, NEVER auto-reset + t.Fatal(err) + } + if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.LastSuccess = "2026-10-01T02:00:00Z" }); err != nil { t.Fatal(err) } var events []string @@ -170,3 +175,71 @@ func TestOffbox_ConfirmedReset(t *testing.T) { t.Fatal("state not cleared after confirmed reset") } } + +// R-726, decision 78: a CLAIMED box that has NEVER made an off-site copy (a returning household's new box, night +// one) sets the old copy aside by itself and makes its first copy — as an unclaimed box does. The old copy is +// moved, never deleted, and recorded so it can be put back. Red-proof: drop `|| firstNight` and this fails while +// TestOffbox_OrphanDetection_Claimed still passes. +func TestR726_ReturningHouseholdFirstNightSetsAside(t *testing.T) { + m, sett := newOffboxManager(t) + if err := sett.SetClaimed(); err != nil { + t.Fatal(err) + } + var events []string + m.SetOffboxOrphanEvent(func(evt, _ string) { events = append(events, evt) }) + var sshCmds []string + m.SetOffboxSSH(func(_ context.Context, _, _ string, _ int, _, _, remoteCmd string) ([]byte, error) { + sshCmds = append(sshCmds, remoteCmd) + if strings.HasPrefix(remoteCmd, "test -e") { + return nil, fmt.Errorf("exit status 1") + } + return nil, nil + }) + var seen []string + m.SetOffboxRunner(wrongPwRunner(&seen)) + if err := m.RunOffboxBackup(context.Background()); err != nil { + t.Fatalf("the first night must make a copy, got %v", err) + } + if m.OffboxOrphaned() { + t.Fatal("the returning household's box stayed orphaned") + } + got := sett.GetOffboxTarget() + if !strings.Contains(got.OrphanedRenamedTo, ".orphaned-") { + t.Fatalf("the old copy's new place is not recorded (it must be listable and restorable): %q", got.OrphanedRenamedTo) + } + for _, c := range sshCmds { + if strings.HasPrefix(c, "rm ") || strings.Contains(c, "rm -") { + t.Fatalf("something was deleted: %q", c) + } + } + backedUp := false + for _, s := range seen { + backedUp = backedUp || s == "init" + } + if !backedUp { + t.Fatalf("no fresh repository was started: %v", seen) + } + if len(events) != 2 || events[0] != "offbox_repo_orphaned" || events[1] != "offbox_repo_reset" { + t.Fatalf("events = %v, want [orphaned reset]", events) + } +} + +// A box whose repository is NOT orphaned changes nothing (no move, no reset event). +func TestR726_NotOrphanedChangesNothing(t *testing.T) { + m, sett := newOffboxManager(t) + _ = sett.SetClaimed() + var events []string + m.SetOffboxOrphanEvent(func(evt, _ string) { events = append(events, evt) }) + var sshCmds []string + m.SetOffboxSSH(func(_ context.Context, _, _ string, _ int, _, _, remoteCmd string) ([]byte, error) { + sshCmds = append(sshCmds, remoteCmd) + return nil, nil + }) + m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { return nil, nil }) + if err := m.RunOffboxBackup(context.Background()); err != nil { + t.Fatalf("run: %v", err) + } + if len(events) != 0 || len(sshCmds) != 0 || sett.GetOffboxTarget().OrphanedRenamedTo != "" { + t.Fatalf("a healthy repository was touched: events=%v ssh=%v", events, sshCmds) + } +} diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index 4c9a67c..9b97699 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -22,9 +22,9 @@ import ( // Pinned image tags — NEVER ":latest" (a floating tag breaks reproducible golden bakes and lets the // deployed version drift). Verified to resolve on Docker Hub before baking. const ( - TraefikImage = "traefik:v3.6.7" - CloudflaredImage = "cloudflare/cloudflared:2026.6.0" - FileBrowserImage = "gtstef/filebrowser:1.3.3-stable" + TraefikImage = "traefik:v3.7.13" + CloudflaredImage = "cloudflare/cloudflared:2026.9.3" + FileBrowserImage = "gtstef/filebrowser:1.5.6-stable" // FileBrowserImportMount is the in-container mount point NAME for the canonical drop-zone // (R-75): the bind lands at /srv/. ASCII and space-free on purpose — it appears in a // container path, in the generated compose, and (percent-encoded) in the deep-link URL. diff --git a/controller/internal/stacks/infra.go b/controller/internal/stacks/infra.go index fcacd51..74ddcb0 100644 --- a/controller/internal/stacks/infra.go +++ b/controller/internal/stacks/infra.go @@ -5,6 +5,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec" "os" "path/filepath" + "regexp" "sort" "strings" @@ -250,7 +251,7 @@ func changedInfraFiles(dir string, files map[string]infra.FileSpec) []string { func (m *Manager) ensureFileBrowser(dir string) error { if containerRunning("filebrowser") { - return nil + return m.reconcileFileBrowserImage(dir) } composePath := filepath.Join(dir, "docker-compose.yml") if _, err := os.Stat(composePath); err == nil { @@ -411,3 +412,27 @@ func containerRunning(name string) bool { } return strings.TrimSpace(string(out)) == "true" } + +var composeImageLine = regexp.MustCompile(`(?m)^(\s*image:\s*)(\S+)\s*$`) + +// reconcileFileBrowserImage moves a RUNNING file browser to the pinned image (R-838, controller v0.291.0). Its compose +// file is owned by web.SyncFileBrowserMounts (the storage mounts), which runs only when storage changes — so before +// this, a release that raised FileBrowserImage never reached an installed box. Only the `image:` line is replaced; +// the mounts stay byte-for-byte. Same image (or no compose file) → nothing. Pinned by TestReconcileFileBrowserImage_*. +func (m *Manager) reconcileFileBrowserImage(dir string) error { + composePath := filepath.Join(dir, "docker-compose.yml") + cur, err := os.ReadFile(composePath) + if err != nil { + return nil // nothing provisioned by us here: leave it + } + mm := composeImageLine.FindSubmatch(cur) + if mm == nil || string(mm[2]) == infra.FileBrowserImage { + return nil + } + m.logger.Printf("[INFO] [infra] filebrowser runs %s, the pin is %s — moving it (mounts unchanged; the file browser pauses a few seconds)", mm[2], infra.FileBrowserImage) + out := composeImageLine.ReplaceAll(cur, []byte("${1}"+infra.FileBrowserImage)) + if err := os.WriteFile(composePath, out, 0o644); err != nil { + return fmt.Errorf("write docker-compose.yml: %w", err) + } + return m.composeUp(dir) +} diff --git a/controller/internal/stacks/infra_tunnel_test.go b/controller/internal/stacks/infra_tunnel_test.go index 50b5628..8411e2c 100644 --- a/controller/internal/stacks/infra_tunnel_test.go +++ b/controller/internal/stacks/infra_tunnel_test.go @@ -255,3 +255,30 @@ func TestEnsureBaseStack_TunnelOrder(t *testing.T) { }) } } + +// R-838: a release that raises FileBrowserImage reaches a RUNNING file browser — only the image line changes (the +// storage mounts SyncFileBrowserMounts wrote stay byte-for-byte) and it is recreated once. Red-proof: make +// ensureFileBrowser return nil for a running container again and the first sub-step fails. +func TestReconcileFileBrowserImage_MovesOnlyTheImage(t *testing.T) { + state := stubDocker(t) + touch(t, filepath.Join(state, "running-filebrowser"), "") + m, calls := tunnelTestManager(t, "") + dir := t.TempDir() + old := strings.Replace(infra.RenderFileBrowserCompose("example.hu", []string{" - /mnt/felhom-drives/d1/userdata:/srv/d1"}), infra.FileBrowserImage, "gtstef/filebrowser:1.3.3-stable", 1) + touch(t, filepath.Join(dir, "docker-compose.yml"), old) + if err := m.ensureFileBrowser(dir); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(filepath.Join(dir, "docker-compose.yml")) + want := strings.Replace(old, "gtstef/filebrowser:1.3.3-stable", infra.FileBrowserImage, 1) + if string(got) != want { + t.Fatalf("compose after the move:\n%s\nwant only the image line changed:\n%s", got, want) + } + if len(*calls) != 1 || (*calls)[0].args != "up -d" { + t.Fatalf("want one `up -d`, got %+v", *calls) + } + // Same image → nothing. + if err := m.ensureFileBrowser(dir); err != nil || len(*calls) != 1 { + t.Fatalf("an up-to-date file browser was touched: err %v calls %+v", err, *calls) + } +} diff --git a/controller/scripts/check-infra-pins.py b/controller/scripts/check-infra-pins.py new file mode 100755 index 0000000..cb8eef8 --- /dev/null +++ b/controller/scripts/check-infra-pins.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""check-infra-pins.py — the MONTHLY re-test's infrastructure half (R-838, 2026-10-04). + +The box's three built-in containers (traefik, cloudflared, filebrowser) are pinned in internal/infra/infra.go and are +NOT catalog templates, so retest-floating.py never sees them. Before R-838 cloudflared sat four months behind and +nothing noticed. This script prints, for each pin, the newest upstream release in the SAME channel (traefik v3.x, +cloudflared YYYY.M.P, filebrowser N.N.N-stable — never a beta) and says BEHIND when the pin is older. + +It REPORTS; it changes nothing. A raise is a controller release (edit the constant, read the release notes between the +two versions for breaking changes, prove it on 9202 then both demo boxes: the runbook's "Infrastructure pins" section). +Network: Docker Hub's public tag API only. Exit 0 = all current, 1 = at least one BEHIND, 2 = could not check. + +Run: python3 scripts/check-infra-pins.py +""" +import json +import os +import re +import sys +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +INFRA = os.path.join(HERE, "..", "internal", "infra", "infra.go") + +CHANNELS = { + "TraefikImage": ("library/traefik", re.compile(r"^v3\.(\d+)\.(\d+)$")), + "CloudflaredImage": ("cloudflare/cloudflared", re.compile(r"^(\d{4})\.(\d+)\.(\d+)$")), + "FileBrowserImage": ("gtstef/filebrowser", re.compile(r"^(\d+)\.(\d+)\.(\d+)-stable$")), +} + + +def pins(): + src = open(INFRA).read() + out = {} + for const in CHANNELS: + m = re.search(r'\b%s\s*=\s*"([^"]+)"' % const, src) + if not m: + raise SystemExit(f"check-infra-pins: {const} not found in {INFRA}") + out[const] = m.group(1) + return out + + +def tags(repo): + url = f"https://hub.docker.com/v2/repositories/{repo}/tags?page_size=100&ordering=last_updated" + names = [] + for _ in range(3): # three pages are plenty for "the newest in a channel" + with urllib.request.urlopen(url, timeout=30) as r: + d = json.load(r) + names += [t["name"] for t in d.get("results", [])] + url = d.get("next") + if not url: + break + return names + + +def key(m): + return tuple(int(x) for x in m.groups()) + + +def main(): + rc = 0 + for const, image in pins().items(): + repo, rx = CHANNELS[const] + tag = image.split(":", 1)[1] + cur = rx.match(tag) + try: + cands = [m for m in (rx.match(t) for t in tags(repo)) if m] + except Exception as e: # noqa: BLE001 — a report, not a gate + print(f"{const:17} {image:40} could not check: {e}") + rc = max(rc, 2) + continue + if not cands or not cur: + print(f"{const:17} {image:40} no comparable tag found") + rc = max(rc, 2) + continue + newest = max(cands, key=key) + state = "current" if key(newest) <= key(cur) else "BEHIND" + if state == "BEHIND": + rc = max(rc, 1) + print(f"{const:17} {image:40} newest {newest.string:16} {state}") + return rc + + +if __name__ == "__main__": + sys.exit(main())