v0.291.0: decision 78 (R-726) — a returning household's first night sets the orphaned copy aside; R-838 — traefik v3.7.13, cloudflared 2026.9.3, filebrowser 1.5.6-stable, a release now moves a running file browser, check-infra-pins.py
gates / gates (push) Successful in 31s
gates / gates (push) Successful in 31s
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<this>. ASCII and space-free on purpose — it appears in a
|
||||
// container path, in the generated compose, and (percent-encoded) in the deep-link URL.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+84
@@ -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())
|
||||
Reference in New Issue
Block a user