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:
@@ -718,6 +718,31 @@ Per-app export creates a self-contained `.fab` file (tar.gz, optionally encrypte
|
||||
The backup system implements a **3-2-1 backup architecture**. Each tier is a **complete,
|
||||
self-sufficient backup** — any single tier can fully restore an app.
|
||||
|
||||
**The restore carries the customer's own previous answers (v0.217.0, R-351).**
|
||||
`internal/backup/offbox_placement.go`. Every recovery unit's `manifest.json` records `drive` and
|
||||
`namespace_root`, and its `compose/app.yaml` records `SUBDOMAIN`/`DOMAIN`. Until v0.217.0 nothing read
|
||||
them back, so a restore into a destination different from the recorded one **succeeded silently**.
|
||||
|
||||
- **`CheckPlacement`** compares the recorded drive against where the restore is about to write,
|
||||
**before the safety dump and before the first byte**. A difference is **named — both values** — and
|
||||
refused. The customer may proceed deliberately with `ack_placement`, a **separate** form field from
|
||||
`confirm=1`: one click must not carry two decisions.
|
||||
- **An UNKNOWN recording is never a mismatch.** A pre-field or unreadable manifest falls back to the
|
||||
previous behaviour rather than blocking, and is never rendered as an empty value.
|
||||
- **The not-installed refusal names where the data belonged**, read from the prepared scratch.
|
||||
- **The deploy page prefills the address and data folder from the app's own backup**
|
||||
(`RecordedUnitForStack`), labelled as coming from the backup and still editable — a memory, not a
|
||||
lock. `RecordedAddress.Known()` requires **both** halves: an absent `SUBDOMAIN` would otherwise
|
||||
surface the *catalog's* default as though the customer had chosen it.
|
||||
- **Where the app's data will live is stated on the deploy page before the button is pressed**
|
||||
(R-352). 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. **Visibility only — no placement changed.**
|
||||
- **Starting a restore is gated by `restoreOpBlocked()`**, which reads the display flag as well as the
|
||||
concurrency flag. Before v0.217.0 a second press started a second run and was told it had.
|
||||
- **The off-site listing's per-app size calls run concurrently, bounded to 4.** Measured before the
|
||||
change: 2605 ms + 5 × 2697 ms ≈ 16 s. The bound protects the Storage Box's session cap; a refused
|
||||
size call returns 0, which under-reports rather than fails visibly.
|
||||
|
||||
**The reserve — per-app backup admission (v0.192.0 decision B2, widened by v0.193.0 / R-181).**
|
||||
`internal/backup/admission.go`. Since the `mp1`→`mp0` merge (R-165) local backups and Docker's
|
||||
data-root share one filesystem, so an unbounded backup write is a stopped box rather than a slow one.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -387,6 +387,22 @@ func (m *Manager) ClassifiedBinds(name string) ([]appbackup.ClassifiedBind, bool
|
||||
return appbackup.ClassifyBinds(meta.Backup, binds)
|
||||
}
|
||||
|
||||
// HasDeployField reports whether the app declares a deploy field for the given env var.
|
||||
//
|
||||
// R-351: the caller that needs this is the restore prefill, and the question it is really asking is
|
||||
// "does this app have somewhere to PUT a recorded value?". Measured 2026-08-21: only 13 of the 53
|
||||
// catalog templates declare `env_var: HDD_PATH`; the other 40 have no storage field at all and their
|
||||
// data lands on the system drive. For those, a recorded placement is a FACT TO STATE, never a value
|
||||
// to offer — writing it into a field that does not exist would be a prefill nobody can see or change.
|
||||
func (m *Metadata) HasDeployField(envVar string) bool {
|
||||
for _, f := range m.DeployFields {
|
||||
if f.EnvVar == envVar {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasDeployFields returns true if the app has any user-facing deploy fields
|
||||
// (i.e., fields beyond auto-filled domain and auto-generated secrets).
|
||||
func (m *Metadata) HasDeployFields() bool {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user