b5d78d1e0f
The systemic complaint, twice in one evening: you press a button and nothing happens. No progress, no ETA, no named result. Three worst offenders, fixed on the two patterns already here (deploy 3-step panel, storage-init status poll). No new framework — that is a ROADMAP item; three targeted cards ship tonight. 4a — a verification restore names its result. The flash said the app had been restored "to a verification folder on the drive"; which folder, on which drive, was invisible, so the customer could not go and look at what they had just asked for. Full path now. The restore page gained a listing of existing verification copies (app, size, date, path) — nothing anywhere showed these, so they piled up and the only way to find them was SSH — each with a double-confirmed delete. That delete is the only one this release adds, so it names a STACK, never a path: the Manager resolves the name inside a backups/offsite-restore root it computed itself and refuses anything landing outside. Red-proofed — neutralise the name guard and stack:"" resolves to the offsite-restore ROOT and takes every copy with it. Refusals are asserted as non-effects. 4b — Megosztás enable shows what it is waiting for. Enabling ran ReconcileSamba synchronously inside the POST handler; on a golden without felhom-samba baked that is compose pulling ~100MB, i.e. minutes of an apparently-hung form post followed by "Beállítás mentve." whether or not anything came up. Detached + polled now, distinguishing "képfájl letöltése" from "indítás" — decided BEFORE the work starts, since afterwards the image is always present. Success is probed, not inferred (compose up -d exits 0 on a crash-loop). The password form starts the same job: with UserSet false reconcile deploys nothing, so on a fresh box that is where the pull actually happens. 4c — "Távoli mentés most" streams real progress. restic was already reporting bytes and percent; the runner seam used CombinedOutput() and discarded them. The manual run now passes --json and scans stdout line-by-line: total bytes, percent, current app. Manual only — the nightly stays silent, pinned by a test that fails if it ever passes --json. The poll now arms unconditionally, closing a race the manual trigger always ran: the redirect rendered before the goroutine wrote LastStatus=running, so the poll never armed and the page sat static during the very run just started. Red-proofed twice. Also closes the golden/controller infra-image drift at the source: infra.Images() derives from the existing pins and --print-infra-images exposes it, so the golden bake can stop carrying its own copy. That copy had already drifted — felhom-samba was never added, so the golden baked 3 of 4, which is why enabling Megosztás pulled at runtime in the first place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
87 lines
2.7 KiB
Go
87 lines
2.7 KiB
Go
package infra
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestImagesCoversEveryPin is the anti-drift gate for the golden bake.
|
|
//
|
|
// Images() feeds `felhom-controller --print-infra-images`, which build-golden.sh uses to decide what
|
|
// to pre-pull into the appliance image. If someone adds a fifth infra stack — a new `FooImage` const
|
|
// — and forgets to add it to Images(), the golden silently bakes 4 of 5 and enabling that stack on a
|
|
// fresh box goes back to being a multi-minute silent registry pull. That is exactly how felhom-samba
|
|
// was missed, so this test reads the CONST BLOCK OUT OF THE SOURCE rather than restating the list:
|
|
// a hand-written expected list would need the same edit and would rot the same way.
|
|
func TestImagesCoversEveryPin(t *testing.T) {
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, "infra.go", nil, 0)
|
|
if err != nil {
|
|
t.Fatalf("parsing infra.go: %v", err)
|
|
}
|
|
|
|
pins := map[string]string{} // const name -> image ref
|
|
for _, decl := range f.Decls {
|
|
gd, ok := decl.(*ast.GenDecl)
|
|
if !ok || gd.Tok != token.CONST {
|
|
continue
|
|
}
|
|
for _, spec := range gd.Specs {
|
|
vs, ok := spec.(*ast.ValueSpec)
|
|
if !ok || len(vs.Names) != 1 || len(vs.Values) != 1 {
|
|
continue
|
|
}
|
|
name := vs.Names[0].Name
|
|
if !strings.HasSuffix(name, "Image") {
|
|
continue
|
|
}
|
|
lit, ok := vs.Values[0].(*ast.BasicLit)
|
|
if !ok || lit.Kind != token.STRING {
|
|
continue
|
|
}
|
|
val, err := strconv.Unquote(lit.Value)
|
|
if err != nil {
|
|
t.Fatalf("unquoting %s: %v", name, err)
|
|
}
|
|
pins[name] = val
|
|
}
|
|
}
|
|
|
|
if len(pins) == 0 {
|
|
t.Fatal("found no *Image consts in infra.go — the parser walk is broken, not the pins")
|
|
}
|
|
|
|
got := map[string]bool{}
|
|
for _, img := range Images() {
|
|
got[img] = true
|
|
}
|
|
for name, img := range pins {
|
|
if !got[img] {
|
|
t.Errorf("const %s = %q is not in Images() — the golden bake would not pre-pull it, so "+
|
|
"enabling that stack on a fresh box would block on a silent registry pull", name, img)
|
|
}
|
|
}
|
|
if len(Images()) != len(pins) {
|
|
t.Errorf("Images() has %d entries but infra.go declares %d *Image consts (%v) — they must "+
|
|
"correspond one-to-one", len(Images()), len(pins), pins)
|
|
}
|
|
}
|
|
|
|
// TestImagesArePinned guards the other half of the contract: a floating tag would make the golden
|
|
// bake unreproducible (the bake and a later deploy could resolve the same name to different digests).
|
|
func TestImagesArePinned(t *testing.T) {
|
|
for _, img := range Images() {
|
|
if !strings.Contains(img, ":") {
|
|
t.Errorf("infra image %q has no tag — implicitly :latest", img)
|
|
continue
|
|
}
|
|
if strings.HasSuffix(img, ":latest") {
|
|
t.Errorf("infra image %q is :latest — a floating tag breaks reproducible golden bakes", img)
|
|
}
|
|
}
|
|
}
|