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

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:
2026-08-21 21:29:01 +02:00
parent 985388c6e9
commit f94543ee5c
14 changed files with 1508 additions and 396 deletions
@@ -0,0 +1,189 @@
package web
import (
"bytes"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-351 — RENDER TESTS, ONE PER BRANCH.
//
// A green build and a green vet say NOTHING about a template: `index` against an undefined key, or a
// method with a pointer receiver, both compile, both pass vet, both pass the suite, and both 500 at
// render. This repo has that on file twice ("template methods need value receivers", "seam built but
// never wired"). The existing deploy render test does not reach these branches — it renders a page
// with only AutoFields, so the subdomain and path blocks never execute — which is precisely how a
// broken branch stays green.
//
// Every case below therefore RENDERS and asserts on the HTML.
const (
prefillDataDrive = "/mnt/felhom-drives/hdd_1"
prefillOtherDriv = "/mnt/felhom-drives/hdd_2"
prefillSysDrive = "/mnt/sys_drive"
)
// renderDeployWithFields renders the deploy page for an app that HAS user-facing fields, so the
// subdomain and path branches are actually executed.
func renderDeployWithFields(t *testing.T, declaresPath bool, extra map[string]interface{}) string {
t.Helper()
s := securityHarness(t)
s.loadTemplates()
fields := []stacks.DeployField{
{EnvVar: "SUBDOMAIN", Label: "Aldomain", Type: "subdomain", Default: "katalogus-alap"},
}
if declaresPath {
fields = append(fields, stacks.DeployField{EnvVar: "HDD_PATH", Label: "Adatmeghajto", Type: "path"})
}
data := map[string]interface{}{
"Page": "stacks", "Title": "Telepites", "Domain": "example.hu",
"Stack": stacks.Stack{Name: "demoapp"},
"Meta": stacks.Metadata{DisplayName: "DemoApp", Slug: "demoapp"},
"AlreadyDeployed": false,
"UserFields": fields,
"StoragePaths": []DeployStoragePath{
{StoragePath: settings.StoragePath{Path: prefillDataDrive, Label: "USB 1"}, FreeHuman: "100 GB", FreePercent: 50},
{StoragePath: settings.StoragePath{Path: prefillOtherDriv, Label: "USB 2", IsDefault: true}, FreeHuman: "200 GB", FreePercent: 80},
},
// The defaults the handler always sets. A test that omitted them would be testing a page the
// handler never produces.
"RestoreFieldValues": map[string]string{},
"RestorePrefillHDDPath": "",
"RestoreRecordedDrive": "",
"RestoreRecordedAddress": "",
"RestoreRecordedDeclaresPath": declaresPath,
"RestoreHasRecord": false,
"SystemDataPath": prefillSysDrive,
}
for k, v := range extra {
data[k] = v
}
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, "deploy", data); err != nil {
t.Fatalf("render deploy: %v", err)
}
return buf.String()
}
// SCENARIO D — the ordinary path. No backup record: the catalog default stands and the configured
// default drive stays selected. WRONG OUTCOME: a new question or obstacle where there was none.
func TestDeployPrefill_NoRecord_LeavesTheOrdinaryPathAlone(t *testing.T) {
html := renderDeployWithFields(t, true, nil)
if !strings.Contains(html, `value="katalogus-alap"`) {
t.Error("with no recorded address the catalog default must still fill the subdomain field")
}
// The configured default drive keeps its selection.
if !strings.Contains(html, `value="`+prefillOtherDriv+`" data-free-percent="80"`) {
t.Fatal("fixture: the default drive option must render, or the assertion below proves nothing")
}
if !optionSelected(html, prefillOtherDriv) {
t.Error("with no record the configured default drive must remain the selected option")
}
if strings.Contains(html, "saját mentése alapján") {
t.Error("no record means no prefill notice — claiming one would be a fabricated fact")
}
}
// SCENARIO A — the record exists and is offered back, visibly labelled as coming from the backup.
func TestDeployPrefill_WithRecord_OffersTheRecordedValuesAndSaysWhy(t *testing.T) {
html := renderDeployWithFields(t, true, map[string]interface{}{
"RestoreFieldValues": map[string]string{"SUBDOMAIN": "konyvek", "DOMAIN": "example.hu", "HDD_PATH": prefillDataDrive},
"RestorePrefillHDDPath": prefillDataDrive,
"RestoreRecordedDrive": prefillDataDrive,
"RestoreRecordedAddress": "konyvek.example.hu",
"RestoreHasRecord": true,
})
if !strings.Contains(html, `value="konyvek"`) {
t.Error("the recorded subdomain must be prefilled — this is the value the person had to remember")
}
if strings.Contains(html, `value="katalogus-alap"`) {
t.Error("the catalog default must NOT win over the customer's own recorded answer")
}
if !optionSelected(html, prefillDataDrive) {
t.Error("the drive the BACKUP recorded must be preselected, not the configured default")
}
if optionSelected(html, prefillOtherDriv) {
t.Error("the configured default must not also be selected — two selected options is a broken form")
}
// The origin must be stated. An unexplained prefill is indistinguishable from a default.
for _, must := range []string{"saját mentése alapján", "konyvek.example.hu", prefillDataDrive} {
if !strings.Contains(html, must) {
t.Errorf("the notice must state %q so the prefill is not mistaken for a default", must)
}
}
// A MEMORY, NOT A LOCK. The whole ruling turns on the customer still being able to change these,
// so the input itself must carry neither `disabled` nor `readonly`.
if tag, ok := inputTag(html, "SUBDOMAIN"); !ok {
t.Error("the subdomain input must render, or the prefill has nowhere to live")
} else if strings.Contains(tag, "disabled") || strings.Contains(tag, "readonly") {
t.Errorf("the prefill is a memory, not a lock — the field must stay editable; got: %s", tag)
}
}
// inputTag returns the rendered <input> tag for a field, so an assertion can be scoped to it rather
// than searching the whole page (where `disabled` legitimately appears on other controls).
func inputTag(html, envVar string) (string, bool) {
i := strings.Index(html, `id="field-`+envVar+`"`)
if i < 0 {
return "", false
}
end := strings.Index(html[i:], ">")
if end < 0 {
return "", false
}
return html[i : i+end], true
}
// THE 40-OF-53 CLASS — no storage field exists, so the placement is STATED as a fact and never
// written into an input that is not there. Part 1's visibility line names the system drive.
func TestDeployPrefill_NoDeclaredPath_StatesWhereTheDataGoes(t *testing.T) {
html := renderDeployWithFields(t, false, map[string]interface{}{
"RestoreRecordedDrive": prefillSysDrive,
"RestoreRecordedAddress": "gist.example.hu",
"RestoreHasRecord": true,
"RestoreFieldValues": map[string]string{"SUBDOMAIN": "gist", "DOMAIN": "example.hu"},
})
if strings.Contains(html, `name="HDD_PATH"`) {
t.Error("an app that declares no data path must not grow a storage field from the prefill")
}
if !strings.Contains(html, "rendszermeghajtóra") || !strings.Contains(html, prefillSysDrive) {
t.Error("Part 1: the page must say where the data will live BEFORE the button is pressed")
}
if !strings.Contains(html, `value="gist"`) {
t.Error("the recorded address still applies to this class — only the folder has no field")
}
}
// Part 1's line must appear for the declaring class too, pointing at the selection. Its absence on
// one branch is how "we told the customer" becomes true only half the time.
func TestDeployPrefill_DeclaredPath_StillSaysWhereTheDataGoes(t *testing.T) {
html := renderDeployWithFields(t, true, nil)
if !strings.Contains(html, "kiválasztott adatmeghajtóra") {
t.Error("an app WITH a storage field must still be told that the choice is where its data lands")
}
if strings.Contains(html, "rendszermeghajtóra") {
t.Error("the system-drive sentence belongs only to apps with no storage field")
}
}
// optionSelected reports whether the <option> for path p carries `selected`. Written against the
// rendered option rather than a substring search for "selected", which would match any option.
func optionSelected(html, p string) bool {
i := strings.Index(html, `value="`+p+`" data-free-percent=`)
if i < 0 {
return false
}
end := strings.Index(html[i:], ">")
if end < 0 {
return false
}
return strings.Contains(html[i:i+end], "selected")
}
+44
View File
@@ -466,6 +466,50 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
if alreadyDeployed && decryptedEnv != nil {
data["DeployedFieldValues"] = decryptedEnv
}
// R-351 SCENARIO A — an app being reinstalled so its data can come back should not ask the
// customer to remember what their own backup already recorded. The address and the data folder
// are read from the most readable recovery unit (local file reads; no network, no restore) and
// offered as a PREFILL the customer may change — a memory, not a lock.
//
// Only for a NOT-deployed app: on the ordinary path (installed, unchanged) nothing here runs and
// the page is byte-identical to before. Scenario D is protected by that condition, not by luck.
//
// An UNKNOWN is never rendered as a value. RecordedAddress.Known() requires BOTH halves, because
// the live deploy path substitutes the catalog's default subdomain — a guess, not the customer's
// answer — and offering that back as "what your backup says" would fabricate a fact.
//
// Every key below is set UNCONDITIONALLY (to its zero value when there is no record), because a
// Go template that does `index` or `eq` against an undefined key errors at RENDER time — green
// build, green vet, green suite, 500 on the page. That trap is on file in this repo twice.
declaresDataPath := meta.HasDeployField("HDD_PATH")
data["RestoreFieldValues"] = map[string]string{}
data["RestorePrefillHDDPath"] = ""
data["RestoreRecordedDrive"] = ""
data["RestoreRecordedAddress"] = ""
data["RestoreRecordedDeclaresPath"] = declaresDataPath
data["RestoreHasRecord"] = false
// Part 1's visibility line needs the real path, not a literal in a template.
data["SystemDataPath"] = s.cfg.Paths.SystemDataPath
if !alreadyDeployed && s.backupMgr != nil {
if place, addr, ok := s.backupMgr.RecordedUnitForStack(name); ok {
prefill := map[string]string{}
if addr.Known() {
prefill["SUBDOMAIN"] = addr.Subdomain
prefill["DOMAIN"] = addr.Domain
}
// The folder is offered as a VALUE only when this app actually has a field for it. The
// 40-of-53 apps that declare no data path have nothing to change — for them the placement
// is stated as a fact, never written into an input that does not exist.
if place.Known() && declaresDataPath {
prefill["HDD_PATH"] = place.Drive
data["RestorePrefillHDDPath"] = place.Drive
}
data["RestoreFieldValues"] = prefill
data["RestoreRecordedDrive"] = place.Drive
data["RestoreRecordedAddress"] = addr.FQDN()
data["RestoreHasRecord"] = place.Known() || addr.Known()
}
}
// Storage paths with free space info for deploy dropdown
var deployPaths []DeployStoragePath
for _, sp := range s.settings.GetSchedulableStoragePaths() {
+34 -2
View File
@@ -455,6 +455,32 @@
{{end}}
<form id="deploy-form" class="deploy-form">
{{/* R-351 SCENARIO A — the values below came from this app's OWN backup, so a reinstall does
not ask the customer to remember what the backup already recorded. Stated, never silent:
a prefilled field whose origin is unexplained is indistinguishable from a default. */}}
{{if and (not .AlreadyDeployed) .RestoreHasRecord}}
<div class="alert alert-info" style="margin-bottom:1rem">
<svg class="ico ico-sm"><use href="#i-info"/></svg>
Ennek az alkalmazásnak van korábbi mentése, ezért az alábbi mezőket a saját mentése alapján töltöttük ki.
{{if .RestoreRecordedAddress}}Korábbi webcím: <strong>{{.RestoreRecordedAddress}}</strong>.{{end}}
{{if .RestoreRecordedDrive}}Az adatai itt voltak: <strong>{{.RestoreRecordedDrive}}</strong>.{{end}}
Ha most máshová szeretnéd telepíteni, nyugodtan átírhatod — a visszaállítás előtt jelezni fogjuk az eltérést.
</div>
{{end}}
{{/* R-351 / Part 1 — WHERE THE DATA WILL LIVE, before the button is pressed. Measured
2026-08-21: 13 of 53 catalog templates declare a storage field; the other 40 have none and
their data goes to the system drive, which no screen said. This states it. It changes no
placement — that is a separate ruling and a separate session. */}}
{{if not .AlreadyDeployed}}
<div class="form-hint" style="margin-bottom:1rem">
{{if .RestoreRecordedDeclaresPath}}
Az alkalmazás adatai az alább kiválasztott adatmeghajtóra kerülnek.
{{else}}
<strong>Hol lesznek az adatok:</strong> ennél az alkalmazásnál nincs külön adatmeghajtó-választás,
ezért az adatai a rendszermeghajtóra kerülnek (<code>{{.SystemDataPath}}</code>). A mentései így is elkészülnek.
{{end}}
</div>
{{end}}
{{if .DockerBelowReserve}}
<div class="alert alert-warning" style="margin-bottom:1rem">
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> Nincs elég szabad tárhely a telepítéshez. Jelenleg {{.DockerFreeHuman}} szabad, és a rendszer
@@ -521,7 +547,9 @@
<div class="subdomain-input-group">
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control subdomain-input"
value="{{if and $.AlreadyDeployed $.DeployedFieldValues}}{{index $.DeployedFieldValues .EnvVar}}{{else}}{{.Default}}{{end}}"
{{/* R-351: a reinstall offers the address the customer's own backup recorded,
ahead of the catalog default. Editable — a domain can legitimately change. */}}
value="{{if and $.AlreadyDeployed $.DeployedFieldValues}}{{index $.DeployedFieldValues .EnvVar}}{{else if index $.RestoreFieldValues .EnvVar}}{{index $.RestoreFieldValues .EnvVar}}{{else}}{{.Default}}{{end}}"
placeholder="aldomain"
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
required
@@ -578,7 +606,11 @@
{{range $.StoragePaths}}
<option value="{{.Path}}" data-free-percent="{{printf "%.0f" .FreePercent}}"
{{if .NotAllowed}}disabled{{end}}
{{if $.AlreadyDeployed}}{{if eq .Path $.CurrentHDDPath}}selected{{end}}{{else if and .IsDefault (not .NotAllowed)}}selected{{end}}>
{{/* R-351: on a reinstall the drive the BACKUP recorded wins over the
configured default — it is where this app's data actually lived, and
restoring into a different drive is the mismatch the restore then has
to stop and name. The customer can still pick another. */}}
{{if $.AlreadyDeployed}}{{if eq .Path $.CurrentHDDPath}}selected{{end}}{{else if $.RestorePrefillHDDPath}}{{if and (eq .Path $.RestorePrefillHDDPath) (not .NotAllowed)}}selected{{end}}{{else if and .IsDefault (not .NotAllowed)}}selected{{end}}>
{{.Label}} — {{.FreeHuman}} szabad{{if .NotAllowed}} ({{.NotAllowedNote}}){{else if .IsDefault}} (alapértelmezett){{end}}
</option>
{{end}}