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