v0.217.0: prefill from the app's own backup, where-the-data-goes on deploy, bounded inventory fan-out
gates / gates (push) Successful in 10s
gates / gates (push) Successful in 10s
Completes R-351 and ships R-352's visibility half. Gates 11/11 OK, suite 28 packages ok, go vet clean, -race clean on the changed package - all run and read BEFORE this commit. PART 2 SCENARIO A - the deploy page prefills the address and data folder from the app's OWN backup. backup.RecordedUnitForStack scans every readable namespace root (the app is NOT installed in this case, so there is no own drive to ask) and reads manifest.json plus the captured compose/app.yaml. Local file reads only: no network, no restic, no restore. RecordedAddress.Known() requires BOTH halves on purpose - an absent SUBDOMAIN makes the live deploy path substitute the CATALOG default (stacks/deploy.go:88-90), and offering that back as "what your backup says" would be a fabricated fact. The prefill is labelled as coming from the backup and stays editable: a memory, not a lock. PART 1 VISIBILITY (R-352) - the deploy page now states where the app's data will live before the button is pressed. Measured 2026-08-21: 13 of 53 catalogue templates declare a storage field; the other 40 have none and their data goes to the system drive, which no screen said. Metadata.HasDeployField answers "does this app have somewhere to PUT a recorded value?" - for the 40-class a recorded placement is a fact to state, never a value written into a field that does not exist. NO PLACEMENT CHANGED. NOTHING MIGRATED. The rest is a filed specification. PART 4 - measured before theorising, on the live off-site target: snapshots --json 2605 ms once; stats 2697 ms PER APP, sequential, 5 app tags => 2605 + 5*2697 = ~16.1 s, matching the reported ten-to-fifteen seconds. The cause is the shape already on file, so the per-app size calls now run concurrently, BOUNDED TO 4. The bound is the safety property, not the speed one: the repository is a Hetzner Storage Box with a session cap, and a refused size call returns SizeBytes 0 - a silent UNDER-REPORT of the customer's data rather than a visible failure. Peak-in-flight is asserted. OffsiteInventoryList had no test at all before this. TEMPLATE SAFETY - every Restore* key is set UNCONDITIONALLY in the deploy handler, because a template doing index/eq against an undefined key errors at RENDER time: green build, green vet, green suite, 500 on the page. Four render tests, one per branch, because the existing deploy render test only renders AutoFields and never reaches these blocks. RED-PROOFS, mutation asserted applied then reverted to 0: A three template guards dropped (count asserted 3) -> the blank form returned P4 inventorySizeConcurrency = 1 -> "peak in flight was 1", elapsed 282ms = sequential DOCS: CHANGELOG v0.217.0 (MinAgent 0.129.0 unchanged), CONTEXT (the restore's own memory + what is next), controller/README.md (Backup System), REUSE.md (4 new rows), REPORT.md overwritten - the previous REPORT preserved to audits/REPORT-v0.216.0-2026-08-14.md first. NOT fixed here, filed as R-353 and named the next session's first item: a restore whose unit carries no db_dumps and no volume_dumps still reports a bare completion.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -113,14 +114,59 @@ func (m *Manager) OffsiteInventoryList(ctx context.Context) (OffsiteInventory, e
|
||||
// empty app list without the Empty flag; the page renders the honest in-between wording.
|
||||
return inv, nil
|
||||
}
|
||||
// R-351 Part 4 — THE SIZE CALLS RUN CONCURRENTLY, BOUNDED.
|
||||
//
|
||||
// MEASURED before changing anything, on demo-hp against the live off-site target
|
||||
// (u629488-sub3.your-storagebox.de:23), 2026-08-21:
|
||||
//
|
||||
// restic snapshots --json (once, whole repo) 2605 ms
|
||||
// restic stats --mode restore-size (per app) 2697 ms each, 5 app tags, SEQUENTIAL
|
||||
// => 2605 + 5*2697 = ~16.1 s
|
||||
//
|
||||
// which is the ten-to-fifteen seconds the page was reported to take. The cause is the shape
|
||||
// already on file — one network call per app, one after another — so the fix is the same one:
|
||||
// run them at once. Each call is an independent SSH round-trip to the repository and `stats` is
|
||||
// a READ (restic takes a shared lock), so they do not contend.
|
||||
//
|
||||
// WHY BOUNDED, and why the bound is small: the target is a Hetzner Storage Box, which caps
|
||||
// concurrent SSH sessions. Unbounded fan-out over a large app list would trade a slow page for
|
||||
// refused connections — and a refused size call degrades to SizeBytes 0, i.e. it would quietly
|
||||
// UNDER-REPORT the customer's own data rather than fail loudly. Four keeps well clear of the cap
|
||||
// and still collapses the common case to a single wave.
|
||||
const inventorySizeConcurrency = 4
|
||||
|
||||
type sized struct {
|
||||
app OffsiteInventoryApp
|
||||
err error
|
||||
}
|
||||
results := make([]sized, 0, len(newest))
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, inventorySizeConcurrency)
|
||||
for tag, n := range newest {
|
||||
app := OffsiteInventoryApp{App: tag, LatestAt: n.at}
|
||||
if size, serr := m.offboxSnapshotSize(ctx, n.id); serr == nil {
|
||||
app.SizeBytes = size
|
||||
} else {
|
||||
m.logger.Printf("[WARN] [offbox] inventory: size of %s's newest snapshot unknown: %v (listing it anyway)", tag, serr)
|
||||
wg.Add(1)
|
||||
go func(tag, id string, at time.Time) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
app := OffsiteInventoryApp{App: tag, LatestAt: at}
|
||||
size, serr := m.offboxSnapshotSize(ctx, id)
|
||||
if serr == nil {
|
||||
app.SizeBytes = size
|
||||
}
|
||||
mu.Lock()
|
||||
results = append(results, sized{app: app, err: serr})
|
||||
mu.Unlock()
|
||||
}(tag, n.id, n.at)
|
||||
}
|
||||
wg.Wait()
|
||||
// Logging happens on the caller's goroutine, after the fan-out: m.logger is shared and the
|
||||
// per-app WARN is the only thing that tells an operator a size is missing rather than zero.
|
||||
for _, r := range results {
|
||||
if r.err != nil {
|
||||
m.logger.Printf("[WARN] [offbox] inventory: size of %s's newest snapshot unknown: %v (listing it anyway)", r.app.App, r.err)
|
||||
}
|
||||
inv.Apps = append(inv.Apps, app)
|
||||
inv.Apps = append(inv.Apps, r.app)
|
||||
}
|
||||
sort.Slice(inv.Apps, func(i, j int) bool { return inv.Apps[i].App < inv.Apps[j].App })
|
||||
return inv, nil
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// inventoryFixture wires a Manager whose repository answers a fixed snapshot list, and whose per-app
|
||||
// `stats` calls are observable: how many ran at once, how many in total, and which ones fail.
|
||||
//
|
||||
// The delay is what makes the concurrency assertion meaningful — with instant calls a sequential
|
||||
// implementation could pass a peak-of-1 check by finishing before the next one starts.
|
||||
func inventoryFixture(t *testing.T, tags []string, statDelay time.Duration, failFor map[string]bool) (*Manager, *int32, *int32, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
m, sett := newOffboxManager(t)
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
||||
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
|
||||
Schedule: "daily", EscrowState: "escrowed",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.OffboxConfigured() {
|
||||
t.Fatal("fixture: the target must be configured or the listing exits early")
|
||||
}
|
||||
var logbuf bytes.Buffer
|
||||
m.logger = log.New(&logbuf, "", 0)
|
||||
|
||||
var snaps []string
|
||||
for i, tag := range tags {
|
||||
snaps = append(snaps, fmt.Sprintf(`{"short_id":"snap%d","time":"2026-08-0%dT06:00:00Z","tags":["%s"]}`, i, i+1, tag))
|
||||
}
|
||||
snapshotsJSON := "[" + strings.Join(snaps, ",") + "]"
|
||||
|
||||
var inFlight, peak, total int32
|
||||
var mu sync.Mutex
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
if contains(args, "snapshots") {
|
||||
return []byte(snapshotsJSON), nil
|
||||
}
|
||||
if !contains(args, "stats") {
|
||||
return nil, nil
|
||||
}
|
||||
atomic.AddInt32(&total, 1)
|
||||
cur := atomic.AddInt32(&inFlight, 1)
|
||||
mu.Lock()
|
||||
if cur > peak {
|
||||
peak = cur
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(statDelay)
|
||||
atomic.AddInt32(&inFlight, -1)
|
||||
// args carries the snapshot id; map it back to its tag position.
|
||||
for i, tag := range tags {
|
||||
if contains(args, fmt.Sprintf("snap%d", i)) {
|
||||
if failFor[tag] {
|
||||
return nil, fmt.Errorf("injected failure for %s", tag)
|
||||
}
|
||||
return []byte(`{"total_size":1048576}`), nil
|
||||
}
|
||||
}
|
||||
return []byte(`{"total_size":0}`), nil
|
||||
})
|
||||
return m, &peak, &total, &logbuf
|
||||
}
|
||||
|
||||
// R-351 PART 4 — the per-app size calls run AT ONCE, and never more than the bound at once.
|
||||
//
|
||||
// MEASURED on demo-hp against the live off-site target before any change (2026-08-21):
|
||||
// snapshots --json 2605 ms once, then stats --mode restore-size 2697 ms PER APP, sequential, over
|
||||
// 5 app tags — 2605 + 5*2697 = ~16.1 s, which is the reported ten-to-fifteen seconds.
|
||||
//
|
||||
// THE BOUND IS THE SAFETY PROPERTY, not the speed one. The repository is a Hetzner Storage Box with
|
||||
// a concurrent-SSH-session cap; a size call that is refused returns SizeBytes 0, which is a SILENT
|
||||
// UNDER-REPORT of the customer's own data rather than a visible failure. So the peak is asserted,
|
||||
// not just the total.
|
||||
func TestOffsiteInventoryList_SizeCallsAreConcurrentButBounded(t *testing.T) {
|
||||
tags := []string{"immich", "nextcloud", "opengist", "paperless-ngx", "privatebin", "calibre-web", "vaultwarden"}
|
||||
m, peak, total, _ := inventoryFixture(t, tags, 40*time.Millisecond, nil)
|
||||
|
||||
start := time.Now()
|
||||
_, err := m.OffsiteInventoryList(context.Background())
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("inventory: %v", err)
|
||||
}
|
||||
|
||||
if int(*total) != len(tags) {
|
||||
t.Errorf("every app needs its own size call: got %d, want %d", *total, len(tags))
|
||||
}
|
||||
if *peak < 2 {
|
||||
t.Errorf("the size calls must run concurrently; peak in flight was %d — that is the sequential shape that cost 16s", *peak)
|
||||
}
|
||||
if *peak > 4 {
|
||||
t.Errorf("peak in flight was %d, above the bound of 4 — an unbounded fan-out risks refused connections, "+
|
||||
"and a refused size call silently under-reports the customer's data", *peak)
|
||||
}
|
||||
// 7 apps at 40ms: sequential would be >=280ms, four-at-a-time is two waves (~80ms).
|
||||
if elapsed >= time.Duration(len(tags))*40*time.Millisecond {
|
||||
t.Errorf("elapsed %v is the sequential cost — the fan-out is not taking effect", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing may be LOST or REORDERED by the fan-out. A missing app reads as "you have no backup of
|
||||
// this", which is the worst possible thing for this page to say by accident.
|
||||
func TestOffsiteInventoryList_FanOutLosesNothingAndStaysSorted(t *testing.T) {
|
||||
tags := []string{"vaultwarden", "immich", "calibre-web", "opengist", "nextcloud"}
|
||||
m, _, _, _ := inventoryFixture(t, tags, time.Millisecond, nil)
|
||||
|
||||
inv, err := m.OffsiteInventoryList(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("inventory: %v", err)
|
||||
}
|
||||
if len(inv.Apps) != len(tags) {
|
||||
t.Fatalf("apps listed = %d, want %d — the fan-out dropped one, which reads as a missing backup", len(inv.Apps), len(tags))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, a := range inv.Apps {
|
||||
seen[a.App] = true
|
||||
if a.SizeBytes != 1048576 {
|
||||
t.Errorf("%s: size = %d, want the injected value", a.App, a.SizeBytes)
|
||||
}
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if !seen[tag] {
|
||||
t.Errorf("%s is missing from the listing", tag)
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(inv.Apps); i++ {
|
||||
if inv.Apps[i-1].App > inv.Apps[i].App {
|
||||
t.Fatalf("the listing must stay sorted; %q came before %q", inv.Apps[i-1].App, inv.Apps[i].App)
|
||||
}
|
||||
}
|
||||
if inv.Empty {
|
||||
t.Error("a repository with snapshots is not Empty")
|
||||
}
|
||||
}
|
||||
|
||||
// A FAILED size call must still list the app, with size 0, AND still log the WARN. The warning is
|
||||
// the only thing that distinguishes "0 bytes" from "we could not tell" — and under a fan-out it is
|
||||
// the easiest thing to lose, because the logging moved off the goroutine that produced the error.
|
||||
func TestOffsiteInventoryList_FailedSizeStillListsAndStillWarns(t *testing.T) {
|
||||
tags := []string{"immich", "opengist"}
|
||||
m, _, _, logbuf := inventoryFixture(t, tags, time.Millisecond, map[string]bool{"opengist": true})
|
||||
|
||||
inv, err := m.OffsiteInventoryList(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("a per-app size failure must not fail the whole listing: %v", err)
|
||||
}
|
||||
if len(inv.Apps) != 2 {
|
||||
t.Fatalf("both apps must be listed; got %d", len(inv.Apps))
|
||||
}
|
||||
for _, a := range inv.Apps {
|
||||
if a.App == "opengist" && a.SizeBytes != 0 {
|
||||
t.Errorf("an unknown size must be 0, got %d", a.SizeBytes)
|
||||
}
|
||||
if a.App == "immich" && a.SizeBytes == 0 {
|
||||
t.Error("the healthy app's size must survive its neighbour's failure")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logbuf.String(), "size of opengist's newest snapshot unknown") {
|
||||
t.Errorf("the WARN is what separates \"0 bytes\" from \"could not tell\"; log was: %s", logbuf.String())
|
||||
}
|
||||
if strings.Contains(logbuf.String(), "size of immich's newest snapshot unknown") {
|
||||
t.Error("a healthy app must not be warned about")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// R-351 — THE RESTORE ALREADY KNOWS WHERE THE APP LIVED. IT JUST NEVER LOOKED.
|
||||
@@ -87,6 +89,117 @@ func CheckPlacement(man *RecoveryManifest, liveDrive, liveNamespaceRoot string)
|
||||
return c
|
||||
}
|
||||
|
||||
// RecordedAddress is the web address the backup recorded for an app, read from the app.yaml the
|
||||
// recovery unit captured beside its manifest.
|
||||
//
|
||||
// RECORDED, NEVER INFERRED. Both halves must come from the captured file. When the captured app.yaml
|
||||
// carries no SUBDOMAIN, the LIVE deploy path falls back to the catalog's default
|
||||
// (stacks/deploy.go:88-90) — that default is a catalog guess, not the customer's answer, and
|
||||
// presenting it as "what your backup says" would be a fabricated fact. This project has ruled twice
|
||||
// that a guess dressed as a fact is how these bugs are built, so an absent SUBDOMAIN is UNKNOWN here.
|
||||
type RecordedAddress struct {
|
||||
Subdomain string
|
||||
Domain string
|
||||
}
|
||||
|
||||
// Known reports whether BOTH halves were recorded. A half-known address is not an address: showing
|
||||
// „gist." or „.enkisfelhom.hu" as a prefill is worse than showing nothing.
|
||||
func (a RecordedAddress) Known() bool {
|
||||
return strings.TrimSpace(a.Subdomain) != "" && strings.TrimSpace(a.Domain) != ""
|
||||
}
|
||||
|
||||
// FQDN is the address as the customer knows it, or "" when it is not fully known.
|
||||
func (a RecordedAddress) FQDN() string {
|
||||
if !a.Known() {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(a.Subdomain) + "." + strings.TrimSpace(a.Domain)
|
||||
}
|
||||
|
||||
// unitAppConfig is the slice of the captured app.yaml this package needs. Deliberately a LOCAL
|
||||
// minimal struct rather than stacks.AppConfig: internal/stacks imports nothing from here today and
|
||||
// reaching across for one map would couple the backup layer to the deploy layer's schema for no
|
||||
// gain. Unknown keys are ignored by yaml.v3, so a richer app.yaml still parses.
|
||||
type unitAppConfig struct {
|
||||
Env map[string]string `yaml:"env"`
|
||||
}
|
||||
|
||||
// RecordedUnitForStack reads what an app's most readable recovery unit says about where it lived and
|
||||
// what address it answered on. Returns ok=false when no unit can be read at all.
|
||||
//
|
||||
// SEARCH ORDER, and why it is not just "the app's own drive": the case this exists for is an app
|
||||
// that is NOT INSTALLED on a rebuilt box, so GetStackHDDPath returns "" and there is no own drive to
|
||||
// consult. It therefore checks every namespace root the box can currently see — the registered
|
||||
// storage paths and the system data path — and takes the first unit it can read. That is a handful
|
||||
// of stat calls on local disk: no network, no restic, no restore.
|
||||
//
|
||||
// A drive that is NOT attached contributes nothing, which is the honest outcome: we cannot read a
|
||||
// unit that is not here, and the reconstitution's own refusal owns that case with a route.
|
||||
func (m *Manager) RecordedUnitForStack(stack string) (RecordedPlacement, RecordedAddress, bool) {
|
||||
if !isSafeStackName(stack) {
|
||||
return RecordedPlacement{}, RecordedAddress{}, false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var roots []string
|
||||
addRoot := func(drive string) {
|
||||
drive = strings.TrimSpace(drive)
|
||||
if drive == "" {
|
||||
return
|
||||
}
|
||||
r := m.namespaceRoot(drive)
|
||||
if r != "" && !seen[r] {
|
||||
seen[r] = true
|
||||
roots = append(roots, r)
|
||||
}
|
||||
}
|
||||
// The app's own drive first when it HAS one — that unit is the authoritative one for an
|
||||
// installed app, and checking it first keeps the common case to a single stat.
|
||||
if m.stackProvider != nil {
|
||||
addRoot(m.stackProvider.GetStackHDDPath(stack))
|
||||
}
|
||||
if m.settings != nil {
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
addRoot(sp.Path)
|
||||
}
|
||||
}
|
||||
addRoot(m.systemDataPath)
|
||||
|
||||
for _, root := range roots {
|
||||
unit := RecoveryUnitPath(root, stack)
|
||||
man := readManifest(filepath.Join(unit, "manifest.json"))
|
||||
if man == nil {
|
||||
continue
|
||||
}
|
||||
place := RecordedPlacement{
|
||||
Drive: strings.TrimSpace(man.Drive),
|
||||
NamespaceRoot: strings.TrimSpace(man.NamespaceRoot),
|
||||
}
|
||||
addr := readRecordedAddress(filepath.Join(unit, "compose", "app.yaml"))
|
||||
if !place.Known() && !addr.Known() {
|
||||
continue // a unit that tells us nothing is not an answer
|
||||
}
|
||||
return place, addr, true
|
||||
}
|
||||
return RecordedPlacement{}, RecordedAddress{}, false
|
||||
}
|
||||
|
||||
// readRecordedAddress parses SUBDOMAIN/DOMAIN out of a captured app.yaml. Returns the zero value on
|
||||
// any failure — absent file, unreadable, malformed, or either half missing.
|
||||
func readRecordedAddress(appYAMLPath string) RecordedAddress {
|
||||
raw, err := os.ReadFile(appYAMLPath)
|
||||
if err != nil {
|
||||
return RecordedAddress{}
|
||||
}
|
||||
var cfg unitAppConfig
|
||||
if yaml.Unmarshal(raw, &cfg) != nil || cfg.Env == nil {
|
||||
return RecordedAddress{}
|
||||
}
|
||||
return RecordedAddress{
|
||||
Subdomain: strings.TrimSpace(cfg.Env["SUBDOMAIN"]),
|
||||
Domain: strings.TrimSpace(cfg.Env["DOMAIN"]),
|
||||
}
|
||||
}
|
||||
|
||||
// scratchManifestMaxDepth bounds the walk below. The unit sits a handful of levels under the scratch
|
||||
// root (the restore mirrors the snapshot's absolute path), and an unbounded walk over a scratch that
|
||||
// also holds a full userdata tree would stat a customer's entire library to find one small file.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeRecordedUnit lays down a recovery unit the way a capture would: manifest.json plus a compose/ dir
|
||||
// holding the app.yaml. Returns the unit path so a test can corrupt or truncate it.
|
||||
func writeRecordedUnit(t *testing.T, nsRoot, stack string, man *RecoveryManifest, appYAML string) string {
|
||||
t.Helper()
|
||||
unit := RecoveryUnitPath(nsRoot, stack)
|
||||
if err := os.MkdirAll(filepath.Join(unit, "compose"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if man != nil {
|
||||
if err := writeManifest(filepath.Join(unit, "manifest.json"), man); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if appYAML != "" {
|
||||
if err := os.WriteFile(filepath.Join(unit, "compose", "app.yaml"), []byte(appYAML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return unit
|
||||
}
|
||||
|
||||
// R-351 SCENARIO A — the values the customer had to remember are in their own backup.
|
||||
//
|
||||
// The 2026-08-21 walk-through needed two of them: the web address and the data folder. Both are in
|
||||
// the unit — the folder in manifest.json, the address in the captured app.yaml — and nothing read
|
||||
// either back. This is the read.
|
||||
func TestRecordedUnitForStack_ReadsBothValues(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
drive := t.TempDir()
|
||||
addSchedulablePath(t, sett, drive) // the existing helper, not a second spelling of it
|
||||
// The app is NOT installed — no stack provider entry. This is the rebuilt-box shape, and the
|
||||
// whole reason the lookup cannot simply ask the live app where it lives.
|
||||
writeRecordedUnit(t, m.namespaceRoot(drive), "calibre-web",
|
||||
&RecoveryManifest{SchemaVersion: 2, AppName: "calibre-web", Drive: drive, NamespaceRoot: drive},
|
||||
"env:\n DOMAIN: enkisfelhom.hu\n SUBDOMAIN: books\n HDD_PATH: "+drive+"\n")
|
||||
|
||||
place, addr, ok := m.RecordedUnitForStack("calibre-web")
|
||||
if !ok {
|
||||
t.Fatal("a readable unit on an attached drive must be found — otherwise there is nothing to prefill")
|
||||
}
|
||||
if place.Drive != drive {
|
||||
t.Errorf("recorded drive = %q, want %q", place.Drive, drive)
|
||||
}
|
||||
if got := addr.FQDN(); got != "books.enkisfelhom.hu" {
|
||||
t.Errorf("recorded address = %q, want books.enkisfelhom.hu", got)
|
||||
}
|
||||
}
|
||||
|
||||
// THE RULE THAT MATTERS MOST HERE: recorded, never inferred.
|
||||
//
|
||||
// When the captured app.yaml has no SUBDOMAIN, the LIVE deploy path substitutes the catalog default
|
||||
// (stacks/deploy.go:88-90). That default is the CATALOG's guess, not the customer's answer. Offering
|
||||
// it back as "what your backup says" would be a fabricated fact — the exact thing this project has
|
||||
// ruled against twice. An absent half makes the whole address unknown.
|
||||
func TestRecordedAddress_IsNeverInferred(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
appYAML string
|
||||
want string
|
||||
wrong string
|
||||
}{
|
||||
{
|
||||
name: "both halves recorded",
|
||||
appYAML: "env:\n DOMAIN: enkisfelhom.hu\n SUBDOMAIN: gist\n",
|
||||
want: "gist.enkisfelhom.hu",
|
||||
wrong: "-",
|
||||
},
|
||||
{
|
||||
name: "no subdomain recorded",
|
||||
appYAML: "env:\n DOMAIN: enkisfelhom.hu\n",
|
||||
want: "",
|
||||
wrong: "the catalog default offered back as if the backup had recorded it",
|
||||
},
|
||||
{
|
||||
name: "no domain recorded",
|
||||
appYAML: "env:\n SUBDOMAIN: gist\n",
|
||||
want: "",
|
||||
wrong: "a half address like \"gist.\" shown as a prefill",
|
||||
},
|
||||
{
|
||||
name: "no env block at all",
|
||||
appYAML: "deployed: true\n",
|
||||
want: "",
|
||||
wrong: "an empty address rendered as a recorded value",
|
||||
},
|
||||
{
|
||||
name: "malformed yaml",
|
||||
appYAML: "env: [this is not a map\n",
|
||||
want: "",
|
||||
wrong: "a parse failure treated as an answer",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "app.yaml")
|
||||
if err := os.WriteFile(p, []byte(tc.appYAML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readRecordedAddress(p)
|
||||
if got.FQDN() != tc.want {
|
||||
t.Errorf("FQDN = %q, want %q — wrong outcome guarded: %s", got.FQDN(), tc.want, tc.wrong)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if got := readRecordedAddress(filepath.Join(t.TempDir(), "absent.yaml")); got.Known() {
|
||||
t.Errorf("an absent app.yaml must yield no address, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// THE 40-OF-53 CLASS, EXPLICITLY. An app that declares no data path still has its placement recorded
|
||||
// — on the system drive. It must NOT fall into the unknown case just because it has no storage
|
||||
// field: the recorded value is still the truth, and the mismatch check still applies to it.
|
||||
// Measured shape: opengist, drive=/mnt/sys_drive.
|
||||
func TestRecordedUnitForStack_NoDeclaredDataPathIsStillRecorded(t *testing.T) {
|
||||
m, _ := newOffboxManager(t)
|
||||
sys := t.TempDir()
|
||||
m.systemDataPath = sys
|
||||
|
||||
nsRoot := m.namespaceRoot(sys)
|
||||
writeRecordedUnit(t, nsRoot, "opengist",
|
||||
&RecoveryManifest{SchemaVersion: 2, AppName: "opengist", Drive: sys, NamespaceRoot: nsRoot},
|
||||
"env:\n DOMAIN: enkisfelhom.hu\n SUBDOMAIN: gist\n") // note: NO HDD_PATH — that is the class
|
||||
|
||||
place, addr, ok := m.RecordedUnitForStack("opengist")
|
||||
if !ok {
|
||||
t.Fatal("an app with no declared data path must still have a readable recorded placement")
|
||||
}
|
||||
if !place.Known() {
|
||||
t.Error("the system drive is a recorded destination, not an unknown one")
|
||||
}
|
||||
if place.Drive != sys {
|
||||
t.Errorf("recorded drive = %q, want the system drive %q", place.Drive, sys)
|
||||
}
|
||||
if got := addr.FQDN(); got != "gist.enkisfelhom.hu" {
|
||||
t.Errorf("address = %q, want gist.enkisfelhom.hu", got)
|
||||
}
|
||||
// And the mismatch check applies to it exactly as to any other app.
|
||||
c := CheckPlacement(&RecoveryManifest{Drive: sys}, "/mnt/felhom-drives/hdd_1", "")
|
||||
if !c.Mismatch {
|
||||
t.Error("moving a no-declared-path app to a real drive is a mismatch and must be named")
|
||||
}
|
||||
}
|
||||
|
||||
// No unit anywhere is "we cannot tell", not "it lived nowhere".
|
||||
func TestRecordedUnitForStack_NothingReadableIsNotAnAnswer(t *testing.T) {
|
||||
m, _ := newOffboxManager(t)
|
||||
m.systemDataPath = t.TempDir()
|
||||
if _, _, ok := m.RecordedUnitForStack("opengist"); ok {
|
||||
t.Error("with no unit on any readable root the lookup must report that it cannot tell")
|
||||
}
|
||||
if _, _, ok := m.RecordedUnitForStack("../etc/passwd"); ok {
|
||||
t.Error("an unsafe stack name must be refused before any path is built")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user