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
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:
@@ -985,7 +985,7 @@ height with no magic number to drift as item counts change.
|
||||
|-------|------|----------|
|
||||
| `/backups` | Áttekintés | storage overview, whole-guest Rendszermentés, status stat cards, single-copy warning, **backup-target banner + offer (v0.186.0)** |
|
||||
| `/backups/remote` | Távoli mentés | Felhom-offsite status card (3 states, display-only), tier-3 status block + quota, participation toggles (+ zero-toggle hint; the persisted zero-toggle run-warning is DISPLAY-replaced by a "kijelölés módosult" note once ≥1 app is toggled — `offboxWarningDisplay`, v0.126.0), manual-target form (`#offbox-section`) |
|
||||
| `/backups/apps` | Alkalmazások | schedule, Adatbázisok table, per-app 1./2./3. tier rows (tier-2 config entry; tier-3 actions deep-link to `/backups/remote#offbox-section`) |
|
||||
| `/backups/apps` | Alkalmazások | schedule, Adatbázisok table, per-app 1./2./3. tier rows (tier-2 config entry; tier-3 actions deep-link to `/backups/remote#offbox-section`); **since v0.242.0 a REMOVED app whose recovery unit was kept is listed after the deployed rows („Eltávolítva — visszaállítható") with one action, the unit restore that reinstalls it (R-487) — the list is keyed on the drives, not on what is deployed** |
|
||||
| `/backups/restore` | Visszaállítás | restore panel, offsite restore list (**one „Visszaállítás…" entry per app** since v0.154.0), existing verification copies, .fab download/import loop |
|
||||
| `/backups/restore/app?name=<app>` | Visszaállítás — <app> | **R-48 per-app offsite restore wizard** (v0.154.0). GET-only; three described intent cards (ellenőrzés / hiányzó fájlok / teljes visszaállítás), a visible phase strip, and a server-derived step. Adds NO mutation endpoint — every card posts to the pre-existing `/backup/offbox/{restore,place,reconstitute}` |
|
||||
|
||||
@@ -3786,7 +3786,7 @@ All daily jobs use Europe/Budapest timezone. Skip-if-running prevents concurrent
|
||||
| GET | `/api/stacks/{name}/logs` | Container logs (`?raw=1` for plain text) |
|
||||
| GET | `/api/stacks/{name}/hdd-data` | HDD data paths + sizes — resolved from the app's OWN `app.yaml` `HDD_PATH` (v0.236.0, R-442), never the global config |
|
||||
| GET | `/api/stacks/{name}/backup-data` | Backup data paths + sizes (DB dumps, cross-drive rsync) |
|
||||
| POST | `/api/stacks/{name}/remove` | Remove deployed stack (revert to "not deployed"). `remove_hdd_data: true` deletes the app's folders under its recorded `HDD_PATH` and lists them; **409 + a Hungarian sentence when the data was asked for but its location cannot be resolved or the drive is absent — nothing is touched, the app is kept** (v0.236.0, R-442). `hdd_paths_removed` is `[]` for an SSD app (never `null`); `hdd_paths_missing`, `hdd_note`, `backup_paths_refused` state what was not found / not removed. `remove_backups: true` (v0.240.0, R-474) deletes the app's whole recovery unit, its Tier-2 mirror(s) on any registered drive and its backup preferences (`backup_paths_removed` lists them); **without it the backups AND the Tier-2 record are kept, so the removed app can still be restored from the second drive (R-486)**. Off-site snapshots are never touched by removal |
|
||||
| POST | `/api/stacks/{name}/remove` | Remove deployed stack (revert to "not deployed"). `remove_hdd_data: true` deletes the app's folders under its recorded `HDD_PATH` and lists them; **409 + a Hungarian sentence when the data was asked for but its location cannot be resolved or the drive is absent — nothing is touched, the app is kept** (v0.236.0, R-442). `hdd_paths_removed` is `[]` for an SSD app (never `null`); `hdd_paths_missing`, `hdd_note`, `backup_paths_refused` state what was not found / not removed. `remove_backups: true` (v0.240.0, R-474) deletes the app's whole recovery unit, its Tier-2 mirror(s) on any registered drive and its backup preferences (`backup_paths_removed` lists them); **without it the backups AND the Tier-2 record are kept, so the removed app can still be restored from the second drive (R-486)**. Off-site snapshots are never touched by removal. Since v0.242.0 a removal also clears the app's update hold (R-491) and `volumes_removed` lists the named volumes actually removed, `[]` when none (R-489) |
|
||||
| DELETE | `/api/stacks/{name}` | Delete orphaned stack — same R-442 resolution and refusal shape as `/remove` |
|
||||
| POST | `/api/sync` | Trigger catalog sync |
|
||||
| GET | `/api/system/info` | System info + sync status |
|
||||
@@ -3797,7 +3797,7 @@ All daily jobs use Europe/Budapest timezone. Skip-if-running prevents concurrent
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/backup/status` | Full backup status |
|
||||
| POST | `/api/backup/run` | Trigger manual backup |
|
||||
| GET | `/api/backup/snapshots` | List snapshots (`?stack={name}` for filtering) |
|
||||
| GET | `/api/backup/snapshots` | List snapshots (`?stack={name}` for filtering). Since v0.242.0 it also answers for a REMOVED app whose unit is on a connected drive (R-487) — 404 only when no unit exists anywhere |
|
||||
| POST | `/api/stacks/{name}/cross-backup` | Save cross-drive config |
|
||||
| POST | `/api/stacks/{name}/cross-backup/run` | Trigger cross-drive backup |
|
||||
| GET | `/api/stacks/{name}/cross-backup/status` | Cross-drive status |
|
||||
|
||||
@@ -1747,6 +1747,10 @@ func main() {
|
||||
mux.Handle("/api/sharing/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSharingAPI))))
|
||||
// Guest RAM resize (v0.143.0, R-24): read current allocation/bounds + apply a bounded resize.
|
||||
mux.Handle("/api/system/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSystemAPI))))
|
||||
// R-490 (v0.242.0): the web layer's /api/system/ prefix knows only the memory routes and answered
|
||||
// 404 to /api/system/info, so the monitoring page's memory-distribution card never rendered. An
|
||||
// exact pattern wins over the prefix in ServeMux. Pinned by TestR490_SystemInfoIsMountedAheadOfTheWebPrefix.
|
||||
mux.Handle("/api/system/info", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
|
||||
// Standalone full-server (guest) restart — the "Kiszolgáló újraindítása" maintenance affordance,
|
||||
// a sibling to the controller-only /api/selfrestart. Reuses the agent GuestReboot primitive.
|
||||
mux.Handle("/api/server/reboot", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerReboot))))
|
||||
|
||||
@@ -73,3 +73,52 @@ func TestR475_AdapterReadsEveryTier(t *testing.T) {
|
||||
t.Errorf("the hold must carry UpdateCopyHolds through HoldAfterFailedUpdateHolding (R-479); selectors:%s", h)
|
||||
}
|
||||
}
|
||||
|
||||
// R-490 — /api/system/info reaches the API router; the web layer's /api/system/ prefix used to eat it.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): delete the exact-path mount — this fails.
|
||||
func TestR490_SystemInfoIsMountedAheadOfTheWebPrefix(t *testing.T) {
|
||||
src, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var exact, prefix bool
|
||||
ast.Inspect(src, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) < 2 {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Handle" {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.BasicLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch lit.Value {
|
||||
case `"/api/system/info"`:
|
||||
exact = strings.Contains(fmtNode(call.Args[1]), "apiRouter")
|
||||
case `"/api/system/"`:
|
||||
prefix = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !prefix {
|
||||
t.Fatal("the /api/system/ prefix mount is gone — this test's premise moved")
|
||||
}
|
||||
if !exact {
|
||||
t.Error("/api/system/info must be mounted on the API router ahead of the web layer's /api/system/ prefix (R-490)")
|
||||
}
|
||||
}
|
||||
|
||||
func fmtNode(n ast.Node) string {
|
||||
var names []string
|
||||
ast.Inspect(n, func(x ast.Node) bool {
|
||||
if id, ok := x.(*ast.Ident); ok {
|
||||
names = append(names, id.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return strings.Join(names, " ")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -60,3 +64,58 @@ func TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// R-491 — a removal clears the app's UPDATE hold (and only that kind).
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): remove the ClearUpdateHold call from removeStack — this fails.
|
||||
func TestR491_RemovalClearsTheUpdateHold(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "removeStack" || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "ClearUpdateHold" {
|
||||
found = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if !found {
|
||||
t.Error("removeStack must clear the app's update hold (R-491), or a reinstall starts held")
|
||||
}
|
||||
}
|
||||
|
||||
// R-490 — with a default storage path registered, system-info reports the drive (the fallback every
|
||||
// other reader already had).
|
||||
func TestR490_SystemInfoFallsBackToTheDefaultStoragePath(t *testing.T) {
|
||||
r, sett, _, _ := newSlice4Router(t)
|
||||
r.sett = sett // the fixture router carries no settings; system-info reads the default path from them
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: t.TempDir(), Label: "HDD", Schedulable: true, IsDefault: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/info", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
System struct {
|
||||
HDDConfigured bool `json:"hdd_configured"`
|
||||
} `json:"system"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resp.Data.System.HDDConfigured {
|
||||
t.Errorf("hdd_configured must be true with a default storage path registered; body %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,6 +905,17 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
r.logger.Printf("[WARN] [api] Failed to forget the backup preferences of %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
// R-491 (v0.242.0): an app held after a failed update that the customer then REMOVES is not held
|
||||
// any more — the hold named a copy for a deploy that no longer exists, and every start gate reads
|
||||
// the store, so a reinstall under the same name would begin refused. Measured on demo-hp
|
||||
// 2026-09-13 (nextcloud). An R-379 restore hold is deliberately NOT cleared here.
|
||||
if r.sett != nil {
|
||||
if cleared, err := r.sett.ClearUpdateHold(name); err != nil {
|
||||
r.logger.Printf("[WARN] [api] Failed to clear the update hold of %s: %v", name, err)
|
||||
} else if cleared {
|
||||
r.logger.Printf("[INFO] [api] remove %s: its update hold is cleared with it (R-491)", name)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})
|
||||
|
||||
@@ -978,7 +989,15 @@ func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
info := system.GetInfo(r.cfg.Paths.HDDPath, r.cpuCollector)
|
||||
// R-490 / R-465: the same default-storage-path fallback every other reader of the always-empty
|
||||
// cfg.Paths.HDDPath has; without it hdd_configured was false on every box.
|
||||
hddPath := r.cfg.Paths.HDDPath
|
||||
if r.sett != nil {
|
||||
if p := r.sett.GetDefaultStoragePath(); p != "" {
|
||||
hddPath = p
|
||||
}
|
||||
}
|
||||
info := system.GetInfo(hddPath, r.cpuCollector)
|
||||
// F1: GetInfo now reports the guest RAM cap (from the Docker daemon) as TotalMemMB, but the guest-wide
|
||||
// "used" is not observable from the container. Report the controller's accurate committed-app memory
|
||||
// (sum of running apps' mem requests) as used — a meaningful "allocated of cap" figure for the UI.
|
||||
@@ -993,7 +1012,10 @@ func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
info.MemPercent = float64(used) / float64(info.TotalMemMB) * 100
|
||||
}
|
||||
}
|
||||
syncStatus := r.syncer.Status()
|
||||
var syncStatus interface{}
|
||||
if r.syncer != nil { // nil in tests; production always wires the syncer
|
||||
syncStatus = r.syncer.Status()
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"system": info,
|
||||
"sync_status": syncStatus,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-476 — a refreshed Tier-2 copy is dated by its DATA (the newest dump), not by a manifest that
|
||||
// moves only when the app's definition changes; a PRESERVED package keeps the manifest date (R-403).
|
||||
func TestR476_UnitRestoreDateNamesTheDataTimeWhenTheLegWasRefreshed(t *testing.T) {
|
||||
cov := Tier2Coverage{UnitPackageDate: "2026-09-12T02:15:29Z", UnitDataDate: "2026-09-13T00:30:00Z", CopyLastSuccess: "2026-09-13T01:30:00Z"}
|
||||
if d, preserved := cov.UnitRestoreDate(); d != "2026-09-13T00:30:00Z" || preserved {
|
||||
t.Errorf("refreshed leg: got %q preserved=%v, want the dump's time", d, preserved)
|
||||
}
|
||||
cov.UnitLegPreserved = true
|
||||
if d, preserved := cov.UnitRestoreDate(); d != "2026-09-12T02:15:29Z" || !preserved {
|
||||
t.Errorf("preserved leg: got %q preserved=%v, want the manifest's date", d, preserved)
|
||||
}
|
||||
older := Tier2Coverage{UnitPackageDate: "2026-09-13T02:00:00Z", UnitDataDate: "2026-09-12T00:30:00Z"}
|
||||
if d, _ := older.UnitRestoreDate(); d != "2026-09-13T02:00:00Z" {
|
||||
t.Errorf("data older than the manifest must not move the date backwards: %q", d)
|
||||
}
|
||||
}
|
||||
|
||||
// R-476 — the coverage reader fills the data date from the mirrored unit's own artifacts.
|
||||
func TestR476_CoverageReadsTheUnitDataDate(t *testing.T) {
|
||||
dest := t.TempDir()
|
||||
u := tier2UnitDir(dest)
|
||||
if err := os.MkdirAll(u, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeUnitManifest(t, u, []string{"app.sql"}, nil)
|
||||
cov := tier2CoverageAt(dest)
|
||||
if cov.UnitDataDate == "" {
|
||||
t.Fatal("UnitDataDate must be read from the unit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// r487Provider knows exactly which stacks are deployed: GetStackComposePath answers ok for those and
|
||||
// only those, which is the question ListRestorePoints and the restore ask.
|
||||
type r487Provider struct{ floorProvider }
|
||||
|
||||
func (p *r487Provider) GetStackComposePath(name string) (string, bool) {
|
||||
for _, s := range p.stacks {
|
||||
if s == name {
|
||||
return filepath.Join(p.dir, "stacks", name, "docker-compose.yml"), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func r487Manager(t *testing.T, sysPath string, deployed ...string) (*Manager, *settings.Settings) {
|
||||
t.Helper()
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(os.Stderr, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.SystemDataPath = sysPath
|
||||
m := NewManager(cfg, sett, log.New(os.Stderr, "", 0))
|
||||
m.SetStackProvider(&r487Provider{floorProvider{stacks: deployed, dir: t.TempDir()}})
|
||||
return m, sett
|
||||
}
|
||||
|
||||
// writeR487Unit lays down a readable unit (manifest + one dump) for stack under nsRoot.
|
||||
func writeR487Unit(t *testing.T, nsRoot, stack, display string, dumpAt time.Time) string {
|
||||
t.Helper()
|
||||
u := mkUnit(t, nsRoot, stack)
|
||||
man := RecoveryManifest{SchemaVersion: 2, AppName: stack, DisplayName: display, CreatedAt: "2026-09-12T02:15:29Z"}
|
||||
b, _ := json.Marshal(man)
|
||||
if err := os.WriteFile(UnitManifestFile(u), b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(UnitDBDumpDir(u), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dump := filepath.Join(UnitDBDumpDir(u), stack+".sql")
|
||||
if err := os.WriteFile(dump, []byte("-- dump"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chtimes(dump, dumpAt, dumpAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// the manifest is the OLDER artifact, as on a real box: it moves only when the definition changes
|
||||
older := dumpAt.Add(-24 * time.Hour)
|
||||
if err := os.Chtimes(UnitManifestFile(u), older, older); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// R-487 — a removed app's kept unit is listed; a deployed app's is not; a unit on a registered DATA
|
||||
// drive is found where it sits and carries that drive's label.
|
||||
func TestR487_ListRemovedAppUnits_ListsKeptUnitsOfUndeployedApps(t *testing.T) {
|
||||
sys := t.TempDir()
|
||||
m, sett := r487Manager(t, sys, "kept-app")
|
||||
sysRoot := m.namespaceRoot(sys)
|
||||
writeR487Unit(t, sysRoot, "kept-app", "Kept", time.Now())
|
||||
writeR487Unit(t, sysRoot, "gone-app", "Gone", time.Now())
|
||||
drive := t.TempDir()
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "Külső HDD", Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeR487Unit(t, m.namespaceRoot(drive), "drive-app", "On the drive", time.Now())
|
||||
// a bare directory without a manifest is NOT an offer
|
||||
if err := os.MkdirAll(RecoveryUnitPath(sysRoot, "hollow"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := m.ListRemovedAppUnits()
|
||||
if len(got) != 2 || got[0].StackName != "drive-app" || got[1].StackName != "gone-app" {
|
||||
t.Fatalf("want [drive-app gone-app], got %+v", got)
|
||||
}
|
||||
if got[0].DriveLabel != "Külső HDD" || got[1].DriveLabel != systemDriveLabel {
|
||||
t.Errorf("drive labels: %q / %q", got[0].DriveLabel, got[1].DriveLabel)
|
||||
}
|
||||
if got[1].DisplayName != "Gone" || got[0].UnitDir != RecoveryUnitPath(m.namespaceRoot(drive), "drive-app") {
|
||||
t.Errorf("display/unit dir: %+v", got)
|
||||
}
|
||||
for _, u := range got {
|
||||
if u.StackName == "kept-app" || u.StackName == "hollow" {
|
||||
t.Errorf("%s must not be listed", u.StackName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-487 — the restore picker's snapshot list answers for a removed app instead of 404.
|
||||
func TestR487_ListRestorePointsFindsARemovedAppsUnit(t *testing.T) {
|
||||
sys := t.TempDir()
|
||||
m, _ := r487Manager(t, sys)
|
||||
at := time.Date(2026, 9, 13, 0, 30, 0, 0, time.UTC)
|
||||
writeR487Unit(t, m.namespaceRoot(sys), "gone-app", "Gone", at)
|
||||
|
||||
pts, found := m.ListRestorePoints("gone-app")
|
||||
if !found || len(pts) != 1 {
|
||||
t.Fatalf("want found with one point, got found=%v pts=%+v", found, pts)
|
||||
}
|
||||
if pts[0].ShortID != restorePointShortID || pts[0].Tier != 1 || pts[0].Time != at.Format(time.RFC3339) {
|
||||
t.Errorf("point %+v", pts[0])
|
||||
}
|
||||
if _, found := m.ListRestorePoints("never-existed"); found {
|
||||
t.Error("an app with no unit anywhere must still be not-found")
|
||||
}
|
||||
}
|
||||
|
||||
// R-487 — a removed app's unit kept on a DATA drive is the one the restore opens, not the system
|
||||
// path the drive fallback would name.
|
||||
func TestR487_PrimaryUnitDirForNamesTheRemovedUnitWhereItSits(t *testing.T) {
|
||||
sys := t.TempDir()
|
||||
m, sett := r487Manager(t, sys)
|
||||
drive := t.TempDir()
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := writeR487Unit(t, m.namespaceRoot(drive), "drive-app", "D", time.Now())
|
||||
if got := m.primaryUnitDirFor("drive-app"); got != want {
|
||||
t.Errorf("unit dir: got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RemovedAppUnit is a recovery unit that sits on a registered drive while its app is NOT deployed —
|
||||
// the state „Töröld az adataimat is" leaves behind when the customer keeps the backups (R-487).
|
||||
//
|
||||
// It exists because the unit was restorable through POST /backup/restore the whole time and listed
|
||||
// on NEITHER backup page, so the customer's remove-by-mistake route existed only as an endpoint.
|
||||
// The off-site list had exactly this defect and was fixed by keying it on the STORE (R-237); the
|
||||
// local list is now keyed on the drives the same way — what is on disk decides, not what is deployed.
|
||||
type RemovedAppUnit struct {
|
||||
StackName string
|
||||
DisplayName string // from the unit's own manifest; the stack name when the manifest has none
|
||||
UnitDir string // the recovery-unit directory, backups/primary/<stack> on the drive it sits on
|
||||
DriveLabel string // registered storage label; the system-drive label for the SSD fallback
|
||||
Time string // RFC3339 UTC — newest artifact in the unit (same rule as ListRestorePoints)
|
||||
}
|
||||
|
||||
// primaryUnitRoots names every felhom-data namespace root a recovery unit can sit under: the system
|
||||
// data path and every registered storage path that is still connected. Deduplicated; a disconnected
|
||||
// drive is skipped — a unit nobody can open is not an offer (R-102's rule, one tier down).
|
||||
func (m *Manager) primaryUnitRoots() []string {
|
||||
seen := make(map[string]bool)
|
||||
var roots []string
|
||||
add := func(drive string) {
|
||||
if drive == "" || !filepath.IsAbs(drive) {
|
||||
return
|
||||
}
|
||||
root := m.namespaceRoot(drive)
|
||||
if root == "" || seen[root] {
|
||||
return
|
||||
}
|
||||
seen[root] = true
|
||||
roots = append(roots, root)
|
||||
}
|
||||
add(m.systemDataPath)
|
||||
if m.settings != nil {
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
if sp.Disconnected {
|
||||
continue
|
||||
}
|
||||
add(sp.Path)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// driveLabelForRoot maps a namespace root back to the label the page shows for it.
|
||||
func (m *Manager) driveLabelForRoot(root string) string {
|
||||
if m.systemDataPath != "" && root == m.namespaceRoot(m.systemDataPath) {
|
||||
return systemDriveLabel
|
||||
}
|
||||
if m.settings != nil {
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
if m.namespaceRoot(sp.Path) == root {
|
||||
return m.settings.GetStorageLabel(sp.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// unitNewestArtifact is the unit's data time: the newest of its manifest, .sql dumps and .tar
|
||||
// volume dumps. ONE rule, shared with ListRestorePoints, so the two lists cannot date a unit
|
||||
// differently.
|
||||
func unitNewestArtifact(unitDir string) (time.Time, bool) {
|
||||
fi, err := os.Stat(UnitManifestFile(unitDir))
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
newest := fi.ModTime()
|
||||
newest = newestArtifact(UnitDBDumpDir(unitDir), ".sql", newest)
|
||||
newest = newestArtifact(UnitVolumeDumpDir(unitDir), ".tar", newest)
|
||||
return newest, true
|
||||
}
|
||||
|
||||
// ListRemovedAppUnits walks backups/primary/ on every connected registered drive and returns the
|
||||
// units whose app is not deployed, sorted by stack name. A unit without a readable manifest is not
|
||||
// listed — the restore would fall back to the volume-only path, which is not the offer this row makes.
|
||||
// A nil provider lists nothing: with no provider "not deployed" cannot be told from "unknown", and an
|
||||
// offer to overwrite must fail closed (the isStackDeployed rule).
|
||||
func (m *Manager) ListRemovedAppUnits() []RemovedAppUnit {
|
||||
if m.stackProvider == nil {
|
||||
return nil
|
||||
}
|
||||
deployed := make(map[string]bool)
|
||||
for _, name := range m.knownStackNames() {
|
||||
deployed[name] = true
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var out []RemovedAppUnit
|
||||
for _, root := range m.primaryUnitRoots() {
|
||||
entries, err := os.ReadDir(PrimaryBackupPath(root))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !e.IsDir() || deployed[name] || seen[name] {
|
||||
continue
|
||||
}
|
||||
unitDir := RecoveryUnitPath(root, name)
|
||||
man := readManifest(UnitManifestFile(unitDir))
|
||||
if man == nil {
|
||||
continue
|
||||
}
|
||||
newest, ok := unitNewestArtifact(unitDir)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
display := man.DisplayName
|
||||
if display == "" {
|
||||
display = name
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, RemovedAppUnit{
|
||||
StackName: name,
|
||||
DisplayName: display,
|
||||
UnitDir: unitDir,
|
||||
DriveLabel: m.driveLabelForRoot(root),
|
||||
Time: newest.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].StackName < out[j].StackName })
|
||||
return out
|
||||
}
|
||||
|
||||
// RemovedAppUnitFor returns the removed app's unit, if one exists on a connected drive.
|
||||
func (m *Manager) RemovedAppUnitFor(stackName string) (RemovedAppUnit, bool) {
|
||||
for _, u := range m.ListRemovedAppUnits() {
|
||||
if u.StackName == stackName {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
return RemovedAppUnit{}, false
|
||||
}
|
||||
@@ -39,6 +39,13 @@ func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, fo
|
||||
return nil, false
|
||||
}
|
||||
if _, ok := m.stackProvider.GetStackComposePath(stackName); !ok {
|
||||
// R-487: a removed app whose backups were kept is not deployed, but its unit is on a drive
|
||||
// and POST /backup/restore reinstalls from it. The picker used to be told 404 here while the
|
||||
// restore itself worked — the list is keyed on the drive now, the way R-237 keyed the
|
||||
// off-site list on the store.
|
||||
if u, found := m.RemovedAppUnitFor(stackName); found {
|
||||
return []RestorePoint{{Time: u.Time, ShortID: restorePointShortID, Tier: 1, DriveLabel: u.DriveLabel}}, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,22 @@ type UnitRestoreResult struct {
|
||||
// An unresolvable drive path is still refused inside …At, in the same place and with the same
|
||||
// message, so the order of the checks a caller can observe is unchanged.
|
||||
func (m *Manager) RestoreFromRecoveryUnit(stackName string) (UnitRestoreResult, error) {
|
||||
return m.RestoreFromRecoveryUnitAt(stackName, RecoveryUnitPath(m.namespaceRoot(m.GetAppDrivePath(stackName)), stackName))
|
||||
return m.RestoreFromRecoveryUnitAt(stackName, m.primaryUnitDirFor(stackName))
|
||||
}
|
||||
|
||||
// primaryUnitDirFor names the PRIMARY unit a keep-side restore opens. For a deployed app that is
|
||||
// backups/primary/<stack> on its own drive. For a REMOVED app (R-487) the drive is no longer known
|
||||
// — GetAppDrivePath falls back to the system path — so a unit kept on a data drive was unreachable
|
||||
// and the restore silently took the volume-only fallback. It is now found where it sits.
|
||||
func (m *Manager) primaryUnitDirFor(stackName string) string {
|
||||
if m.stackProvider != nil {
|
||||
if _, deployed := m.stackProvider.GetStackComposePath(stackName); !deployed {
|
||||
if u, found := m.RemovedAppUnitFor(stackName); found {
|
||||
return u.UnitDir
|
||||
}
|
||||
}
|
||||
}
|
||||
return RecoveryUnitPath(m.namespaceRoot(m.GetAppDrivePath(stackName)), stackName)
|
||||
}
|
||||
|
||||
// RestoreFromRecoveryUnitAt is RestoreFromRecoveryUnit with an EXPLICIT recovery-unit directory.
|
||||
|
||||
@@ -94,6 +94,12 @@ type Tier2Coverage struct {
|
||||
// it is a fact about the artifact the restore will actually open. "" means UNKNOWN.
|
||||
UnitPackageDate string
|
||||
UnitLegPreserved bool
|
||||
// UnitDataDate (R-476) — the newest ARTIFACT in the mirrored unit: its dumps' mtime, or the
|
||||
// manifest's when nothing is newer. The manifest moves only when the app's DEFINITION changes
|
||||
// (checksum-skip), while the nightly dumps keep their names and their fresh bytes — so on
|
||||
// demo-hp a copy holding a dump written at 00:30Z was dated by a manifest from the day before.
|
||||
// RFC3339 UTC; "" when the unit is not readable.
|
||||
UnitDataDate string
|
||||
}
|
||||
|
||||
// CanRestore reports whether the FILE restore has any subtree to read at all.
|
||||
@@ -142,6 +148,9 @@ func tier2CoverageAt(destBase string) Tier2Coverage {
|
||||
// R-403: ask the package itself when it was made. Reading the artifact rather than the status
|
||||
// record is what makes this date impossible to overstate.
|
||||
c.UnitPackageDate = unitPackageDate(unitDir)
|
||||
if newest, ok := unitNewestArtifact(unitDir); ok {
|
||||
c.UnitDataDate = newest.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -299,11 +308,19 @@ func (c Tier2Coverage) Tier2CopyDate() (date string, proven bool) {
|
||||
// `12:03:49Z` against a run at `12:14:24Z` — perfectly healthy, and all four would have been told
|
||||
// their package was stale. A warning that fires on everything is a warning nobody reads, which costs
|
||||
// the same as the comforting lie it was meant to replace.
|
||||
//
|
||||
// R-476: when the leg was NOT preserved, the package's date is its DATA time — the newest dump in
|
||||
// the copy — never the manifest's, which moves only when the definition changes and so undersold a
|
||||
// fresh copy by a day. A PRESERVED package keeps the manifest date: nothing in it is newer, and the
|
||||
// R-403 rule that a preserved package is never shown as fresh is what this sits under.
|
||||
func (c Tier2Coverage) UnitRestoreDate() (date string, preserved bool) {
|
||||
if c.UnitPackageDate == "" {
|
||||
copyDate, _ := c.Tier2CopyDate()
|
||||
return copyDate, c.UnitLegPreserved
|
||||
}
|
||||
if !c.UnitLegPreserved && c.UnitDataDate != "" && c.UnitDataDate > c.UnitPackageDate {
|
||||
return c.UnitDataDate, false
|
||||
}
|
||||
return c.UnitPackageDate, c.UnitLegPreserved
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package bootrecon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-456 — written down in 02-controller-module-map.md and pinned here: a stack with one member
|
||||
// REMOVED (absent, not dead) and the rest running reads as running, so the boot sweep does not repair
|
||||
// it; only a present-but-dead supervised member makes it degraded. Measured 2026-09-02 on demo-hp
|
||||
// (`docker rm -f bookstack` with bookstack-db running: not selected; both gone: repaired in 6.3 s).
|
||||
func TestR456_AbsentMemberIsNotABootOrphan(t *testing.T) {
|
||||
present := []stacks.ContainerInfo{{Name: "bookstack-db", State: "running"}}
|
||||
state := stacks.AggregateStateForTest(present)
|
||||
if state != stacks.StateRunning {
|
||||
t.Fatalf("one running member, one absent: state %q, want running (an absent member is not a dead one)", state)
|
||||
}
|
||||
s := stacks.Stack{Name: "bookstack", Deployed: true, State: state, Containers: present,
|
||||
AppConfig: &stacks.AppConfig{Deployed: true, DesiredState: stacks.DesiredStateRunning}}
|
||||
if isBootOrphan(s) {
|
||||
t.Error("a partly-dead stack must NOT be a boot orphan — repairing half a stack beside its live database is not obviously safe (R-456)")
|
||||
}
|
||||
// Control: the same member present but DEAD is degraded, and degraded IS an orphan.
|
||||
dead := []stacks.ContainerInfo{{Name: "bookstack-db", State: "running"}, {Name: "bookstack", State: "exited"}}
|
||||
if st := stacks.AggregateStateForTest(dead); st != stacks.StateDegraded {
|
||||
t.Fatalf("control: a present dead member must read degraded, got %q", st)
|
||||
}
|
||||
s.State, s.Containers = stacks.StateDegraded, dead
|
||||
if !isBootOrphan(s) {
|
||||
t.Error("control: a degraded stack IS a boot orphan")
|
||||
}
|
||||
}
|
||||
@@ -1675,6 +1675,23 @@ func (s *Settings) ClearRestoreHold(stack string) (bool, error) {
|
||||
return true, s.save()
|
||||
}
|
||||
|
||||
// ClearUpdateHold (R-491, v0.242.0) removes an app's hold ONLY when it is an update hold. An R-379
|
||||
// restore hold stays: that one means a database was left in an unknown state and is cleared by the
|
||||
// operator (`-clear-restore-hold`), never by a removal. Returns whether an update hold was cleared.
|
||||
func (s *Settings) ClearUpdateHold(stack string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
h, ok := s.RestoreHolds[stack]
|
||||
if !ok || h.Reason != HoldReasonUpdateFailed {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.RestoreHolds, stack)
|
||||
if s.log != nil {
|
||||
s.log.Printf("[INFO] [settings] update hold CLEARED for %s (the app was removed)", stack)
|
||||
}
|
||||
return true, s.save()
|
||||
}
|
||||
|
||||
// SetDisconnected marks a storage path as disconnected (or connected) and records which stacks were stopped.
|
||||
func (s *Settings) SetDisconnected(path string, disconnected bool, stoppedStacks []string) error {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -478,6 +478,10 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
|
||||
|
||||
// Step 2: Run docker compose down --volumes (keep images for potential redeploy)
|
||||
env := m.stackEnv(stackDir)
|
||||
// R-489 (v0.242.0): the volumes are listed BEFORE and AFTER; the difference is what was removed.
|
||||
// Parsing compose's progress output reported `null` over volumes it did remove — measured five
|
||||
// times on demo-hp 2026-09-13 — because compose prints that progress to a TTY it does not have here.
|
||||
volsBefore := m.projectVolumes(name)
|
||||
output, err := m.composeExecCustomEnv(stackDir, env, "down", "--volumes")
|
||||
if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: compose down output: %s", name, truncateStr(output, 500))
|
||||
@@ -487,12 +491,10 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
|
||||
return resp, fmt.Errorf("docker compose down failed for %s: %w", name, err)
|
||||
}
|
||||
|
||||
// Step 3: Identify removed volumes from compose output
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.Contains(line, "Removing volume") || strings.Contains(line, "Volume") {
|
||||
resp.VolumesRemoved = append(resp.VolumesRemoved, line)
|
||||
}
|
||||
// Step 3: the volumes that are gone now — `[]` when none, never null (R-489).
|
||||
resp.VolumesRemoved = removedVolumes(volsBefore, m.projectVolumes(name))
|
||||
if len(resp.VolumesRemoved) > 0 {
|
||||
m.logger.Printf("[INFO] [stacks] RemoveStack %s: removed volume(s) %v", name, resp.VolumesRemoved)
|
||||
}
|
||||
|
||||
// Step 4: Handle HDD data
|
||||
@@ -848,3 +850,34 @@ func getDirSizeBytes(path string) int64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// projectVolumes lists the named volumes Docker holds for a compose project (by its project label).
|
||||
// A listing failure reads as no volumes, so a removal never fails on bookkeeping.
|
||||
func (m *Manager) projectVolumes(project string) []string {
|
||||
out, err := m.execCommand("docker", "volume", "ls", "--filter", "label=com.docker.compose.project="+project, "--format", "{{.Name}}")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var vols []string
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
vols = append(vols, l)
|
||||
}
|
||||
}
|
||||
return vols
|
||||
}
|
||||
|
||||
// removedVolumes is before minus after, as a non-nil slice (the JSON must read `[]`, not `null`).
|
||||
func removedVolumes(before, after []string) []string {
|
||||
still := map[string]bool{}
|
||||
for _, v := range after {
|
||||
still[v] = true
|
||||
}
|
||||
removed := []string{}
|
||||
for _, v := range before {
|
||||
if !still[v] {
|
||||
removed = append(removed, v)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -1610,3 +1610,9 @@ func (m *Manager) getCatalogTemplateSlugs() map[string]bool {
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
// AggregateStateForTest exposes aggregateState with every member supervised, to tests outside this
|
||||
// package — R-456's pin lives in bootrecon.
|
||||
func AggregateStateForTest(containers []ContainerInfo) ContainerState {
|
||||
return aggregateState(containers, func(string) string { return "unless-stopped" })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-489 — the removal reports the volumes it removed, and `[]` when none — never `null`.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make removedVolumes return nil for an empty result — the JSON
|
||||
// reads null and the second assertion fails.
|
||||
func TestR489_RemovedVolumesIsADifferenceAndNeverNull(t *testing.T) {
|
||||
got := removedVolumes([]string{"app_data", "app_db", "app_cache"}, []string{"app_cache"})
|
||||
if strings.Join(got, ",") != "app_data,app_db" {
|
||||
t.Errorf("before minus after = %v", got)
|
||||
}
|
||||
b, _ := json.Marshal(RemoveResponse{VolumesRemoved: removedVolumes(nil, nil)})
|
||||
if !strings.Contains(string(b), `"volumes_removed":[]`) {
|
||||
t.Errorf("no volumes must read as [] — got %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR489_ProjectVolumesReadsTheDockerListing(t *testing.T) {
|
||||
m := &Manager{}
|
||||
var seen []string
|
||||
m.execFn = func(name string, args ...string) (string, error) {
|
||||
seen = append([]string{name}, args...)
|
||||
return "app_data\napp_db\n\n", nil
|
||||
}
|
||||
got := m.projectVolumes("app")
|
||||
if strings.Join(got, ",") != "app_data,app_db" {
|
||||
t.Errorf("volumes = %v", got)
|
||||
}
|
||||
if !strings.Contains(strings.Join(seen, " "), "label=com.docker.compose.project=app") {
|
||||
t.Errorf("must filter by the compose project label; ran %v", seen)
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -56,6 +56,11 @@ ALLOWLIST = {
|
||||
("backups.html", "amelyből az egész készülék visszaállítható"):
|
||||
"the LOCAL whole-device backup, made and held by the host agent. Nothing to do with the "
|
||||
"off-site escrow claim — no recovery code is involved.",
|
||||
("backups_apps.html", "Eltávolítva — visszaállítható"):
|
||||
"R-487: a REMOVED app's kept recovery unit. The row renders only when the unit's manifest "
|
||||
"is readable on a CONNECTED registered drive (backup.ListRemovedAppUnits), and the claim is "
|
||||
"the very action beside it — POST /backup/restore from that unit. No recovery code, no "
|
||||
"off-site store: what the box can see on its own disk.",
|
||||
("backups_apps.html", "alkalmazásonként visszaállítható"):
|
||||
"per-app restore from the local app-data backup. Same: local, no recovery code.",
|
||||
("backups_remote.html", "mentéseid visszaszerezhetők.</strong>"):
|
||||
|
||||
Reference in New Issue
Block a user