controller v0.242.0: a removed app is listed with its kept backup; five small ones (R-487 R-491 R-490 R-489 R-476 R-456)
gates / gates (push) Successful in 14s

R-487: the local backup lists are keyed on the drives, not on what is
deployed — a removed app whose unit was kept is listed with the restore
that reinstalls it, the picker answers for it, and the restore opens the
unit where it sits. R-491: a removal clears the app's update hold.
R-490: /api/system/info reaches the API router and reads the default
storage path. R-489: volumes_removed is the real before/after difference,
[] when none. R-476: a Tier-2 copy is dated by its data, not its manifest.
R-456: the boot-orphan rule is pinned. Every fix red-proofed.
This commit is contained in:
2026-09-13 22:50:18 +02:00
parent c1f62ddae8
commit d698ce343b
22 changed files with 838 additions and 13 deletions
+24
View File
@@ -1090,6 +1090,8 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
// page listed these, so they accumulated invisibly and the only way to find them was SSH.
if s.backupMgr != nil {
data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies()
// R-487: removed apps whose unit was kept join the local restore picker.
data["RemovedApps"] = s.backupMgr.ListRemovedAppUnits()
}
// R-237: the restore list is driven by the STORE, not by what is deployed and toggled. A rebuilt
// box has neither, and used to be shown nothing at all while its snapshots sat in the repository.
@@ -1209,6 +1211,11 @@ type AppBackupRow struct {
// Drive disconnected — app's home drive is currently disconnected
DriveDisconnected bool
// Removed (R-487) — the app is NOT deployed; this row stands for a recovery unit kept on a
// drive after „Töröld az adataimat is" with the backups kept. Its one action is the unit restore,
// which reinstalls (R-253). RemovedUnitTime is the unit's newest artifact, RFC3339.
Removed bool
RemovedUnitTime string
// Tier2 destination drive is currently disconnected (backup paused, not failed)
Tier2DestDisconnected bool
// Tier2 destination drive is inactive (Schedulable=false, backup paused)
@@ -1458,6 +1465,23 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
rows = append(rows, row)
}
// R-487: the list is keyed on the DRIVES, not on what is deployed — a removed app whose unit was
// kept is listed after the deployed ones, with the restore that brings it back.
if s.backupMgr != nil {
for _, u := range s.backupMgr.ListRemovedAppUnits() {
rows = append(rows, AppBackupRow{
StackName: u.StackName,
DisplayName: u.DisplayName,
Slug: u.StackName,
StorageLabel: u.DriveLabel,
Status: "yellow",
StatusText: "Eltávolított alkalmazás — a mentése megvan, visszaállítható",
Removed: true,
RemovedUnitTime: u.Time,
Tier1LastRun: u.Time,
})
}
}
return rows
}
@@ -0,0 +1,111 @@
package web
import (
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// R-487 — a removed app whose unit was kept renders on the backups page with the restore that brings
// it back; a page without such rows carries none of it (the negative control).
func TestR487_BackupsPageListsARemovedAppWithItsRestore(t *testing.T) {
data := splitTestData()
data["AppBackupRows"] = []AppBackupRow{
{StackName: "calibre-web", DisplayName: "Calibre-Web"},
{StackName: "gone-app", DisplayName: "Gone", Removed: true, RemovedUnitTime: "2026-09-13T00:30:00Z", StorageLabel: "HDD"},
}
html := renderBackupPage(t, "backups_apps", data)
for _, want := range []string{
`name="stack_name" value="gone-app"`,
`name="snapshot_id" value="helyi"`,
`action="/backup/restore"`,
`class="layer-badge">Megtartva<`,
} {
if !strings.Contains(html, want) {
t.Errorf("removed row must render %q", want)
}
}
if strings.Count(html, `class="layer-badge">Megtartva<`) != 1 {
t.Error("the kept badge must render for the removed row only")
}
control := renderBackupPage(t, "backups_apps", splitTestData())
if strings.Contains(control, "Megtartva") || strings.Contains(control, `value="helyi"`) {
t.Error("negative control: a page with no removed app must not offer the restore")
}
}
// R-487 — the restore picker lists removed apps in their own group.
func TestR487_RestorePickerListsRemovedApps(t *testing.T) {
data := splitTestData()
data["RemovedApps"] = []backup.RemovedAppUnit{{StackName: "gone-app", DisplayName: "Gone"}}
html := renderBackupPage(t, "backups_restore", data)
if !strings.Contains(html, `<option value="gone-app" data-removed="true"`) {
t.Error("removed app must be an option in the picker")
}
if strings.Contains(renderBackupPage(t, "backups_restore", splitTestData()), "data-removed") {
t.Error("negative control: no removed apps, no group")
}
}
// r487Provider is the smallest stack provider that can say "this app is not deployed".
type r487Provider struct{ deployed []string }
func (p *r487Provider) GetStackComposePath(name string) (string, bool) {
for _, s := range p.deployed {
if s == name {
return "/stacks/" + name + "/docker-compose.yml", true
}
}
return "", false
}
func (p *r487Provider) ListDeployedStacks() []backup.StackSummary {
out := make([]backup.StackSummary, 0, len(p.deployed))
for _, s := range p.deployed {
out = append(out, backup.StackSummary{Name: s})
}
return out
}
func (p *r487Provider) GetStackHDDMounts(string) []string { return nil }
func (p *r487Provider) GetStackHDDPath(string) string { return "" }
func (p *r487Provider) GetImportRoot() string { return "" }
func (p *r487Provider) GetDockerVolumes(string) []string { return nil }
func (p *r487Provider) StopStack(string) error { return nil }
func (p *r487Provider) StartStack(string) error { return nil }
func (p *r487Provider) RefreshAndIsRunning(string) bool { return true }
func (p *r487Provider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
return backup.RecoveryInfo{}, false
}
func (p *r487Provider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (p *r487Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
return nil
}
func (p *r487Provider) StartStackServices(string, []string) error { return nil }
func (p *r487Provider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
return nil, false
}
// R-487 — the page's row builder appends a removed app's kept unit after the deployed rows, from the
// real manager over a real drive layout (the seam is the provider, and it says only "not deployed").
func TestR487_BuildAppBackupRowsAppendsRemovedApps(t *testing.T) {
s := testServer(t)
sys := t.TempDir()
cfg := &config.Config{}
cfg.Paths.SystemDataPath = sys
mgr := backup.NewManager(cfg, s.settings, s.logger)
mgr.SetStackProvider(&r487Provider{})
s.backupMgr = mgr
unit := backup.RecoveryUnitPath(backup.NamespaceRootFor(sys, sys), "gone-app")
if err := os.MkdirAll(unit, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(backup.UnitManifestFile(unit), []byte(`{"schema_version":2,"app_name":"gone-app","display_name":"Gone","created_at":"2026-09-12T02:15:29Z"}`), 0o644); err != nil {
t.Fatal(err)
}
rows := s.buildAppBackupRows(&backup.FullBackupStatus{})
if len(rows) != 1 || rows[0].StackName != "gone-app" || !rows[0].Removed || rows[0].DisplayName != "Gone" || rows[0].RemovedUnitTime == "" {
t.Fatalf("want one removed row for gone-app, got %+v", rows)
}
}
@@ -147,7 +147,10 @@
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
<span class="app-backup-row-name">{{.DisplayName}}</span>
<div class="app-backup-row-meta">
{{if .DriveDisconnected}}
{{if .Removed}}
<span class="tag tag-warn">Eltávolítva — visszaállítható</span>
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
{{else if .DriveDisconnected}}
<span class="tag tag-warn">Meghajtó leválasztva</span>
{{else if .HasHDDData}}
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
@@ -162,6 +165,26 @@
<span class="expand-icon">▶</span>
</div>
<div class="app-backup-row-detail" style="display:none">
{{if .Removed}}
{{/* R-487: a removed app's kept unit. One action — the same restore the picker on the
Visszaállítás page runs (POST /backup/restore, snapshot „helyi"); it reinstalls the app
from the unit (R-253). No tier rows: nothing is scheduled for an app that is not here. */}}
<div class="backup-layers">
<div class="backup-layer-row">
<span class="tier-label">Mentés</span>
<span class="layer-badge">Megtartva</span>
<span class="tier-location">helyi</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: {{fmtTimeStr .RemovedUnitTime}}.</span>
<div class="layer-actions">
<form method="POST" action="/backup/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="stack_name" value="{{.StackName}}">
<input type="hidden" name="snapshot_id" value="helyi">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) {{.DisplayName}} alkalmazást a megtartott mentésből ({{fmtTimeStr .RemovedUnitTime}})? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
</form>
</div>
</div>
</div>
{{else}}
<div class="backup-layers">
<!-- Tier 1: Nightly backup (mandatory, same drive) -->
<div class="backup-layer-row">
@@ -332,6 +355,7 @@
{{end}}
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
@@ -25,6 +25,14 @@
{{range .Backup.AppDataInfo}}
<option value="{{.StackName}}" data-has-hdd="{{.HasHDDData}}" data-has-db="{{.HasDBDump}}" data-has-volumes="{{.HasVolumeData}}">{{.DisplayName}}</option>
{{end}}
{{/* R-487: removed apps whose unit was kept — the restore reinstalls them (R-253). */}}
{{if .RemovedApps}}
<optgroup label="Eltávolított alkalmazások (mentés megtartva)">
{{range .RemovedApps}}
<option value="{{.StackName}}" data-removed="true" data-has-hdd="false" data-has-db="false" data-has-volumes="true">{{.DisplayName}} — eltávolítva</option>
{{end}}
</optgroup>
{{end}}
</select>
</div>
<div class="restore-form-row">