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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user