diff --git a/REUSE.md b/REUSE.md index 37165ad..bafbc30 100644 --- a/REUSE.md +++ b/REUSE.md @@ -23,7 +23,9 @@ | `HumanizeBytes` | controller/internal/appbackup/appdata.go | `(b int64) string` | Human byte sizes | Exported canonical; private clones exist (§6) | | `stablePathForName` / `agentWhere` | controller/internal/web/intermediary.go | `(name/registeredPath) string` | Map registry stable path `/mnt/felhom-drives/` ↔ raw agent mount | Registry stores STABLE path; agent ops take the RAW mount — always convert | | `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) | -| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, legacy felhom-data) | Consult before ANY recursive delete under a drive | +| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, kept, legacy felhom-data) | Consult before ANY recursive delete under a drive | +| `stacks.OldAppDataPaths` / `Manager.ListKept` / `KeepAside` / `DeleteKept` / `FindKept` | controller/internal/stacks/kept.go | `(composePath, hdd)` / `(drives)` / … | Kept data (`09` §3 decision 36): what counts as an app's old data (ONLY `/appdata/…` binds), the list, start-fresh, the household's delete | **An action names a kept item by path only through `FindKept`** — `DeleteKept` refuses anything not listed. `KeepAside` is a rename on one drive; never copy, never `RemoveAll` in a rollback (`removeEmptyDirs`) | +| `backup.KeptDBCopy` / `KeptCopyAt` / `LoadKeptApp` | controller/internal/backup/kept_load.go | `(app, drive)` / `(unitDir, drive, tier)` / … | Which copy can load kept files, and the load as a restore op | A copy counts only with data (DB dump or volume tar) AND its app.yaml `HDD_PATH` = this drive. Installed apps are never offered. Off-site not looked at | ### Subprocess + timeout + exit-code discipline diff --git a/controller/README.md b/controller/README.md index 10fc6d0..41439bc 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1861,6 +1861,42 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an - Storage overview card (total size across repos, snapshot count, DB dump count/size, encryption key with show/copy) - Restore section: app dropdown → per-app snapshot dropdown (Tier 1 + Tier 2 grouped) → restore type info → confirmation checkbox → execute → import from `.fab` bundle link +### Kept data — the choice at reinstall, the list, the read-only view (`09` §3 decision 36) + +An app removed with „keep my data" leaves its private drive folder (`/appdata/`). Since this release +that folder is never a dead end, and an install never runs into it silently (R-657). + +- **At install.** When the app's `${HDD_PATH}/appdata/…` bind already holds something, `POST /api/stacks//deploy` + answers **409** with `data.code = "kept_data_choice"` and the sentences (title, body, the two choices, „Erről nem + készül mentés." / "This is not backed up.") in the request's language; nothing is written. The page asks, and + sends again with `kept_data`: + - `use` — **„A megőrzött adataimat használom" / "Use my kept data"**: a LOAD from the newest copy that holds the + app's data and was taken of THIS drive (the app's own unit, Tier 1, or a second-drive mirror, Tier 2 — + `backup.KeptDBCopy`), through the one unit-restore body (`RestoreFromRecoveryUnitAt`); then the template's + `after_load:` once. Refused 409 with the `use_off` sentence when no such copy exists. + - `fresh` — **„Tiszta lappal kezdem" / "Start fresh"**: the old folder is MOVED (a rename on the same drive; + EXDEV or any failure puts back what moved and refuses) to `/kept///` with a + `.felhom-kept.json` marker; the removed app's own unit moves in with it (`kept/.../unit`) so the files keep + their database copy; then the normal install. + - no choice → `DeployStack` refuses too (`ErrKeptDataChoice`), before any write. +- **„Megőrzött adatok" / "Kept data"** — `GET /kept-data` (linked from Tárhely → Meghajtók): every dated kept + folder and every non-empty `appdata/` no installed app binds, on every connected local drive — app, date, + size, which copy can bring it back (or none). **Load** (`POST /kept-data/load`, only with a copy; puts a dated + item's files back first, refuses over an occupied folder), **Look** (the file browser), **Delete** + (`POST /kept-data/delete`, the app's name typed to confirm — the ONLY deletion of kept data; the box never + deletes one by itself, D3 is open). +- **Read-only view.** FileBrowser gets a source „Megőrzött adatok" / "Kept data" (box language) at + `/srv/megorzott/`, one `:ro` bind per listed item, present only when something is kept; the list page re-syncs + it (no recreate when nothing changed). Known limit: an app folder owned by another user with mode 0770 + (nextcloud's `www-data`) shows as a folder FileBrowser cannot open. +- **Where it lives.** `/kept/` is beside `appdata/` and `userdata/`, inside neither: no app bind, FileBrowser + userdata source, Samba share or backup leg reads it. It is in `ProtectedHDDPaths`. +- **The drive-full warning** ends with the kept folders on that drive and their sizes (`fillwatch.SetExtra`). +- **R-690 (fixed here).** The removed-app restore (R-487) never found a unit on a DATA drive — it asked + `GetStackComposePath`, true for every catalog app — and restored with no env. It now asks `isStackDeployed`. +- `.felhom.yml` **`after_load: {service, user, command: [...]}`** — one command run with `docker compose exec -T` + after a load; nextcloud declares `php occ files:scan --all`. + --- ### 4. Storage Management diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 64122d3..6d5250c 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -35,6 +35,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/fillwatch" + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "gitea.dooplex.hu/admin/felhom-controller/internal/infra" "gitea.dooplex.hu/admin/felhom-controller/internal/integrations" "gitea.dooplex.hu/admin/felhom-controller/internal/mailrelay" @@ -568,6 +569,8 @@ func main() { // An UNWIRED manager also refuses (fail closed); TestSlice4_UpdateGuardsAreWiredAtStartup walks // this file for the call, because a seam built and never wired has shipped here seven times. stackMgr.SetUpdateGuards(&updateGuardsAdapter{b: backupMgr, q: quiesceLoop}) + // `09` §3 decision 36: a start-fresh moves the removed app's own unit in with its kept files. + stackMgr.SetKeptUnitFinder(backupMgr.RemovedUnitOnDrive) // v0.271.0 (`09` §6.4 part 7): the automatic update leg. The switch is read at the leg's start and // before every press; W is the SAME effective window every nightly leg reads. The files-may-change // mark asks the backup side's truth table (decisions 25/26), never a second definition of "whole". @@ -1428,6 +1431,9 @@ func main() { }) }) } + // `09` §3 decision 36: the warning names the kept folders on the filling drive, with their sizes — + // space the household can free on the kept-data list. Nothing is deleted by the box (D3). + fillWatcher.SetExtra(func(t fillwatch.Target) string { return keptSpaceSentence(stackMgr, t.Path) }) sched.Daily("fill-watch", "03:30", func(ctx context.Context) error { return fillWatcher.Check() }) // AND ONCE SHORTLY AFTER STARTUP. A box that BOOTS with a filesystem already over the line must @@ -3763,3 +3769,17 @@ func budapestLoc() *time.Location { }) return budapestLocVal } + +// keptSpaceSentence is the drive-full warning's kept-data sentence for one drive (Hungarian, like the +// warning it extends): each kept folder by app name and size. "" when the drive holds none. +func keptSpaceSentence(sm *stacks.Manager, drive string) string { + items := sm.ListKept([]string{drive}) + if len(items) == 0 { + return "" + } + parts := make([]string, 0, len(items)) + for _, it := range items { + parts = append(parts, fmt.Sprintf("%s (%s)", it.DisplayName, appbackup.HumanizeBytes(it.SizeBytes))) + } + return util.Text(i18n.Default, "kept.diskwarn", strings.Join(parts, ", ")) +} diff --git a/controller/internal/api/kept_install.go b/controller/internal/api/kept_install.go new file mode 100644 index 0000000..5df1539 --- /dev/null +++ b/controller/internal/api/kept_install.go @@ -0,0 +1,134 @@ +package api + +import ( + "fmt" + "net/http" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// keptChoiceData is what the install page needs to ask the household (`09` §3 decision 36). The +// sentences come rendered in the request's language; the page shows them as they are. +type keptChoiceData struct { + Code string `json:"code"` // "kept_data_choice" + Title string `json:"title"` + Body string `json:"body"` + UseLabel string `json:"use_label"` + UseDesc string `json:"use_desc"` // "" when use is off + UseOff string `json:"use_off"` // "" when use is offered + UseOffered bool `json:"use_offered"` // a database copy exists for these files + FreshLabel string `json:"fresh_label"` + FreshDesc string `json:"fresh_desc"` + NotBacked string `json:"not_backed_up"` +} + +// keptDataAtInstall answers the install when the app's private drive folder already holds data: +// +// - no choice → 409 kept_data_choice with the sentences (nothing moved, nothing installed); +// - "use" → a LOAD from the newest usable copy (backup.KeptDBCopy), the removed-app restore with +// the unit named, then the app's after_load; 202. Refused 409 with use_off when no copy exists; +// - "fresh" → not handled here: DeployStack moves the old data aside and installs. +// +// Returns true when it wrote the response. +func (r *Router) keptDataAtInstall(w http.ResponseWriter, req *http.Request, name, hdd, choice string) bool { + old := r.stackMgr.OldAppData(name, hdd) + if len(old) == 0 || choice == stacks.KeptChoiceFresh { + return false + } + display := name + if st, ok := r.stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" { + display = st.Meta.DisplayName + } + var size int64 + var newest time.Time + for _, p := range old { + size += stacks.DirSizeBytes(p) + if t := stacks.DirModTime(p); t.After(newest) { + newest = t + } + } + var cp struct { + ok bool + time time.Time + dir string + } + if r.backupMgr != nil { + if c, ok := r.backupMgr.KeptDBCopy(name, hdd); ok { + cp.ok, cp.time, cp.dir = true, c.Time, c.UnitDir + } + } + lang := r.langFor(req) + data := keptChoiceData{ + Code: "kept_data_choice", + Title: r.msgLang(lang, "kept.choice.title"), + Body: r.msgLang(lang, "kept.choice.body", display, appbackup.HumanizeBytes(size), localDay(newest)), + UseLabel: r.msgLang(lang, "kept.choice.use.label"), + UseOffered: cp.ok, + FreshLabel: r.msgLang(lang, "kept.choice.fresh.label"), + FreshDesc: r.msgLang(lang, "kept.choice.fresh.desc"), + NotBacked: r.msgLang(lang, "kept.not_backed_up"), + } + if cp.ok { + data.UseDesc = r.msgLang(lang, "kept.choice.use.desc", localDay(cp.time)) + } else { + data.UseOff = r.msgLang(lang, "kept.choice.use_off") + } + + switch choice { + case "": + r.logger.Printf("[INFO] [api] Deploy %s: the drive already holds its old data %v (%d bytes) — asking the household (use offered: %v)", name, old, size, cp.ok) + writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.Title, Data: data}) + return true + case stacks.KeptChoiceUse: + if !cp.ok { + r.logger.Printf("[WARN] [api] Deploy %s: use my kept data REFUSED — no database copy for %s", name, hdd) + writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.UseOff, Data: data}) + return true + } + if st := r.backupMgr.RestoreStatus(); st.Running || r.backupMgr.IsRunning() { + writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msgLang(lang, "api.kept.busy")}) + return true + } + r.logger.Printf("[INFO] [api] Deploy %s: USE MY KEPT DATA — loading from %s (%s) under the kept files %v", name, cp.dir, cp.time.UTC().Format(time.RFC3339), old) + r.startKeptLoad(name, cp.dir, lang, nil) + writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msgLang(lang, "kept.load.started", display)}) + return true + default: + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("kept_data %q is not a choice", choice)}) + return true + } +} + +// startKeptLoad runs the load and, when it succeeded, the app's after_load. then(ok) runs last. +func (r *Router) startKeptLoad(name, unitDir, lang string, then func(ok bool)) { + display := name + if st, ok := r.stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" { + display = st.Meta.DisplayName + } + r.backupMgr.LoadKeptApp(name, unitDir, + func(error) string { return r.msgLang(lang, "kept.load.done", display) }, + func(err error) string { return r.msgLang(lang, "kept.load.failed", display, err) }, + func(ok bool) { + if ok { + if ran, err := r.stackMgr.RunAfterLoad(name, 10*time.Minute); ran && err != nil { + r.logger.Printf("[WARN] [api] kept load %s: after_load failed: %v", name, err) + } + } + if then != nil { + then(ok) + } + }) +} + +func localDay(t time.Time) string { + if t.IsZero() { + return "?" + } + loc, err := time.LoadLocation("Europe/Budapest") + if err != nil { + loc = time.UTC + } + return t.In(loc).Format("2006-01-02") +} diff --git a/controller/internal/api/kept_install_test.go b/controller/internal/api/kept_install_test.go new file mode 100644 index 0000000..600da58 --- /dev/null +++ b/controller/internal/api/kept_install_test.go @@ -0,0 +1,78 @@ +package api + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// `09` §3 decision 36 — the install API answers an install over old data with the CHOICE (409, +// code kept_data_choice, both sentences, "use" OFF when no database copy exists) and installs nothing; +// "use" without a copy is refused; "fresh" is left to DeployStack. Nothing here reaches Docker. +// COMPANION RED-PROOF: make keptDataAtInstall return false at once → the no-choice case writes nothing +// and the first assertion fails (the install would go on to DeployStack). +func TestKept_InstallAPIAsksAndInstallsNothing(t *testing.T) { + dir := t.TempDir() + drive := filepath.Join(dir, "drive") + cfg := &config.Config{} + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Stacks.ComposeCommand = "docker compose" + app := filepath.Join(cfg.Paths.StacksDir, "cloudapp") + for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp")} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + _ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644) + _ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644) + _ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644) + m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + if err := m.ScanStacks(); err != nil { + t.Fatal(err) + } + r := &Router{stackMgr: m, logger: log.New(io.Discard, "", 0)} + + call := func(choice string) (bool, int, map[string]interface{}) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang=en", nil) + handled := r.keptDataAtInstall(w, req, "cloudapp", drive, choice) + var body map[string]interface{} + _ = json.Unmarshal(w.Body.Bytes(), &body) + return handled, w.Code, body + } + handled, code, body := call("") + data, _ := body["data"].(map[string]interface{}) + if !handled || code != http.StatusConflict || data["code"] != "kept_data_choice" { + t.Fatalf("no choice: handled=%v code=%d body=%v", handled, code, body) + } + if data["title"] != "This app's old data is still here" || data["use_offered"] != false || + data["use_off"] != "There is no backup of the database, so the app cannot load the old files. You can look at the files under Kept data." || + data["fresh_label"] != "Start fresh" || data["not_backed_up"] != "This is not backed up." { + t.Fatalf("the choice's sentences: %v", data) + } + if handled, code, _ := call("use"); !handled || code != http.StatusConflict { + t.Fatalf("use with no copy: handled=%v code=%d", handled, code) + } + if handled, _, _ := call("fresh"); handled { + t.Fatal("fresh must be left to DeployStack (which moves the old data aside)") + } + if st, _ := m.GetStack("cloudapp"); st.Deployed || st.Deploying { + t.Fatal("something was installed") + } + // No old data → the API does not interfere. + _ = os.RemoveAll(filepath.Join(drive, "appdata/cloudapp")) + if handled, _, _ := call(""); handled { + t.Fatal("an install with no old data was intercepted") + } +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index ffdf569..30e24ca 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -417,7 +417,7 @@ func (r *Router) getDeployFields(w http.ResponseWriter, req *http.Request, name // Pinned by TestR553_Deploy_DecisionSurvivesWordingChange. func deployStatusFor(err error) int { switch { - case errors.Is(err, stacks.ErrAlreadyDeployed): + case errors.Is(err, stacks.ErrAlreadyDeployed), errors.Is(err, stacks.ErrKeptDataChoice): return http.StatusConflict case errors.Is(err, stacks.ErrRequiredField), errors.Is(err, stacks.ErrPathMissing), errors.Is(err, stacks.ErrNotEnoughMemory): @@ -432,7 +432,8 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri r.dbg("deployStack: name=%s contentLength=%d", name, req.ContentLength) var body struct { - Values map[string]string `json:"values"` + Values map[string]string `json:"values"` + KeptData string `json:"kept_data"` } if err := json.NewDecoder(req.Body).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"}) @@ -490,9 +491,15 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri return } + // `09` §3 decision 36: the app's drive folder already holds its old data → the household chooses. + if handled := r.keptDataAtInstall(w, req, name, body.Values["HDD_PATH"], body.KeptData); handled { + return + } + deployReq := stacks.DeployRequest{ StackName: name, Values: body.Values, + KeptData: body.KeptData, } warning, err := r.stackMgr.DeployStack(deployReq) diff --git a/controller/internal/backup/kept_load.go b/controller/internal/backup/kept_load.go new file mode 100644 index 0000000..3416811 --- /dev/null +++ b/controller/internal/backup/kept_load.go @@ -0,0 +1,143 @@ +package backup + +import ( + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// ── Kept data: which copy can bring it back, and the load (`09` §3 decision 36) ─────────────────── +// +// „Use my kept data" (at install) and „Load" (on the kept-data list) are the SAME act: the app's +// recovery unit (definition + database + volumes) is restored — through RestoreFromRecoveryUnitAt, the +// one body every unit restore uses (R-102) — while its files stay where they are on the drive. It is the +// removed-app restore (R-487) with the unit named explicitly. +// +// WHICH COPY. The newest unit that (1) opens (a readable manifest), (2) holds the app's data state (a +// database dump or a volume tar — a definition alone would install an empty app over the kept files), and +// (3) was taken of THIS drive's install: its app.yaml's HDD_PATH names this drive. Looked for in the +// app's own unit on the drive (Tier 1, R-487) and in every connected second-drive mirror (Tier 2). The +// off-site copy is NOT looked at here: its restore is a different operation (reconstitute) and it is +// not built into this choice yet — said on the page as "none" when it is the only one. + +// KeptCopy is one database copy a kept folder can be loaded from. +type KeptCopy struct { + UnitDir string + Tier int // 1 own unit, 2 second drive + Time time.Time + DriveLabel string +} + +// unitHoldsData: a readable manifest that lists a database dump or a volume tar. +func unitHoldsData(unitDir string) (*RecoveryManifest, bool) { + man := readManifest(UnitManifestFile(unitDir)) + if man == nil { + return nil, false + } + return man, len(man.DBDumps)+len(man.VolumeDumps) > 0 +} + +// unitHDDPath reads the unit's own app.yaml env HDD_PATH (a plain, non-secret field). "" when absent. +func unitHDDPath(unitDir string) string { + b, err := os.ReadFile(filepath.Join(unitDir, "compose", "app.yaml")) + if err != nil { + return "" + } + var doc struct { + Env map[string]string `yaml:"env"` + } + if yaml.Unmarshal(b, &doc) != nil { + return "" + } + return strings.TrimSpace(doc.Env["HDD_PATH"]) +} + +// KeptCopyAt judges one unit directory for drive: usable, and when it was taken. +func (m *Manager) KeptCopyAt(unitDir, drive string, tier int) (KeptCopy, bool) { + if _, ok := unitHoldsData(unitDir); !ok { + return KeptCopy{}, false + } + if h := unitHDDPath(unitDir); h != "" && filepath.Clean(h) != filepath.Clean(drive) { + m.logger.Printf("[INFO] [backup] kept: unit %s was taken of %s, not %s — not offered", unitDir, h, drive) + return KeptCopy{}, false + } + t, ok := unitNewestArtifact(unitDir) + if !ok { + return KeptCopy{}, false + } + return KeptCopy{UnitDir: unitDir, Tier: tier, Time: t}, true +} + +// KeptDBCopy returns the NEWEST usable copy of app's data for kept files on drive: the app's own unit +// (Tier 1) and every connected second-drive mirror (Tier 2). Only for an app that is NOT installed — an +// installed app's unit is its live backup, never a kept-data offer. +func (m *Manager) KeptDBCopy(app, drive string) (KeptCopy, bool) { + if app == "" || m.isStackDeployed(app) { + return KeptCopy{}, false + } + var best KeptCopy + found := false + consider := func(c KeptCopy, ok bool) { + if ok && (!found || c.Time.After(best.Time)) { + best, found = c, true + } + } + if u, ok := m.RemovedAppUnitFor(app); ok { + c, ok := m.KeptCopyAt(u.UnitDir, drive, 1) + c.DriveLabel = u.DriveLabel + consider(c, ok) + } + for _, d := range m.Tier2MirrorDirsForApp(app) { + consider(m.KeptCopyAt(tier2UnitDir(d), drive, 2)) + } + return best, found +} + +// RemovedUnitOnDrive is the start-fresh hook (stacks.SetKeptUnitFinder): the removed app's OWN unit when +// it sits on drive, so it moves into the kept folder with the files it belongs to. "" otherwise. +func (m *Manager) RemovedUnitOnDrive(app, drive string) string { + if m.isStackDeployed(app) { + return "" + } + u, ok := m.RemovedAppUnitFor(app) + if !ok { + return "" + } + if !strings.HasPrefix(filepath.Clean(u.UnitDir), filepath.Clean(drive)+string(filepath.Separator)) { + return "" + } + return u.UnitDir +} + +// PrimaryUnitHome is where app's own unit lives on drive (backups/primary/). +func (m *Manager) PrimaryUnitHome(app, drive string) string { + return RecoveryUnitPath(m.namespaceRoot(drive), app) +} + +// LoadKeptApp runs the load as a restore operation the backup pages already follow (the poll banner): +// Begin → RestoreFromRecoveryUnitAt(app, unitDir) → End, then after(ok) in the same goroutine (the +// after_load command, the kept folder's tidy-up). The caller has checked RestoreStatus/IsRunning. +func (m *Manager) LoadKeptApp(app, unitDir string, okMsg, failMsg func(err error) string, after func(ok bool)) { + m.BeginRestoreOp("restore", app) + go func() { + start := time.Now() + res, err := m.RestoreFromRecoveryUnitAt(app, unitDir) + if err != nil { + m.logger.Printf("[ERROR] [backup] kept load %s from %s FAILED after %s: %v", app, unitDir, time.Since(start).Round(time.Second), err) + m.EndRestoreOp(false, failMsg(err)) + if after != nil { + after(false) + } + return + } + m.logger.Printf("[INFO] [backup] kept load %s from %s done in %s (volumes %d/%d, dbs %d/%d)", app, unitDir, + time.Since(start).Round(time.Second), res.VolumesReplayed, res.ManifestVolumes, res.DBsReplayed, res.ManifestDBs) + m.EndRestoreOp(true, okMsg(nil)) + if after != nil { + after(true) + } + }() +} diff --git a/controller/internal/backup/kept_load_test.go b/controller/internal/backup/kept_load_test.go new file mode 100644 index 0000000..5d3a02f --- /dev/null +++ b/controller/internal/backup/kept_load_test.go @@ -0,0 +1,65 @@ +package backup + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func writeKeptUnit(t *testing.T, unitDir, hdd string, withData bool, at time.Time) { + t.Helper() + if err := os.MkdirAll(filepath.Join(unitDir, "compose"), 0o755); err != nil { + t.Fatal(err) + } + man := RecoveryManifest{SchemaVersion: 2, AppName: "nextcloud"} + if withData { + man.DBDumps = []string{"nextcloud-mariadb.sql"} + } + b, _ := json.Marshal(man) + if err := os.WriteFile(UnitManifestFile(unitDir), b, 0o644); err != nil { + t.Fatal(err) + } + _ = os.Chtimes(UnitManifestFile(unitDir), at, at) + if hdd != "" { + if err := os.WriteFile(filepath.Join(unitDir, "compose", "app.yaml"), []byte("env:\n HDD_PATH: "+hdd+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } +} + +// `09` §3 decision 36 — „use my kept data" loads only a copy that (1) holds the app's data and (2) was +// taken of THIS drive's install; the newest such copy on any local tier wins. +// COMPANION RED-PROOF: drop the HDD_PATH comparison in KeptCopyAt → a unit taken of ANOTHER drive is +// offered and the first assertion fails. +func TestKept_DBCopyIsTheNewestUsableForThisDrive(t *testing.T) { + m, sett, drive := r690Manager(t) + _ = sett + unit := RecoveryUnitPath(m.namespaceRoot(drive), "nextcloud") + + writeKeptUnit(t, unit, "/mnt/felhom-drives/other", true, time.Now().Add(-time.Hour)) + if c, ok := m.KeptDBCopy("nextcloud", drive); ok { + t.Fatalf("a unit taken of another drive was offered: %+v", c) + } + writeKeptUnit(t, unit, drive, false, time.Now().Add(-time.Hour)) + if c, ok := m.KeptDBCopy("nextcloud", drive); ok { + t.Fatalf("a unit holding no data was offered: %+v", c) + } + writeKeptUnit(t, unit, drive, true, time.Now().Add(-time.Hour)) + c, ok := m.KeptDBCopy("nextcloud", drive) + if !ok || c.UnitDir != unit || c.Tier != 1 { + t.Fatalf("own unit not offered: %+v ok=%v", c, ok) + } + // An INSTALLED app's unit is its live backup, never a kept-data offer. + if c, ok := m.KeptDBCopy("still-here", drive); ok { + t.Fatalf("an installed app's unit was offered: %+v", c) + } + // The start-fresh hook names the unit only on the same drive. + if got := m.RemovedUnitOnDrive("nextcloud", drive); got != unit { + t.Fatalf("RemovedUnitOnDrive = %q, want %q", got, unit) + } + if got := m.RemovedUnitOnDrive("nextcloud", "/elsewhere"); got != "" { + t.Fatalf("RemovedUnitOnDrive named a unit on another drive: %q", got) + } +} diff --git a/controller/internal/backup/r690_removed_unit_test.go b/controller/internal/backup/r690_removed_unit_test.go new file mode 100644 index 0000000..e8acfd4 --- /dev/null +++ b/controller/internal/backup/r690_removed_unit_test.go @@ -0,0 +1,62 @@ +package backup + +import ( + "log" + "os" + "path/filepath" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// r690Provider answers GetStackComposePath the way PRODUCTION does (main.go stackAdapter): ok for +// every stack the box knows — deployed or not, because every catalog template is a stack — while +// ListDeployedStacks names only the deployed ones. The R-487 fake answered it for deployed apps +// only, which is why its test passed while the box restored nextcloud with no env (R-690). +type r690Provider struct{ floorProvider } + +func (p *r690Provider) GetStackComposePath(name string) (string, bool) { + return filepath.Join(p.dir, "stacks", name, "docker-compose.yml"), true +} + +func r690Manager(t *testing.T) (*Manager, *settings.Settings, string) { + t.Helper() + sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(os.Stderr, "", 0)) + if err != nil { + t.Fatal(err) + } + sys := t.TempDir() + cfg := &config.Config{} + cfg.Paths.SystemDataPath = sys + m := NewManager(cfg, sett, log.New(os.Stderr, "", 0)) + m.SetStackProvider(&r690Provider{floorProvider{stacks: []string{"still-here"}, dir: t.TempDir()}}) + drive := t.TempDir() + if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil { + t.Fatal(err) + } + return m, sett, drive +} + +// The consequence, not the mechanism: the unit the restore OPENS and the copy the picker OFFERS are +// the removed app's unit on the data drive, while its catalog stack still exists. +// COMPANION RED-PROOF: put GetStackComposePath back as the "removed?" test in primaryUnitDirFor → +// this fails naming the system-drive path; in ListRestorePoints → the picker offers 0 copies. +func TestR690_RemovedUnitFoundWhenTheStackStillExists(t *testing.T) { + m, _, drive := r690Manager(t) + want := writeR487Unit(t, m.namespaceRoot(drive), "nextcloud", "Nextcloud", time.Now()) + + if got := m.primaryUnitDirFor("nextcloud"); got != want { + t.Fatalf("the restore opens %s, want the kept unit %s on the data drive", got, want) + } + pts, found := m.ListRestorePoints("nextcloud") + if !found || len(pts) != 1 || pts[0].DriveLabel != "HDD" { + t.Fatalf("the picker offers %+v (found=%v), want the one kept copy on HDD", pts, found) + } + // Negative control: a DEPLOYED app is never redirected to a removed-app unit. + writeR487Unit(t, m.namespaceRoot(drive), "still-here", "Still", time.Now()) + if got := m.primaryUnitDirFor("still-here"); got == RecoveryUnitPath(m.namespaceRoot(drive), "still-here") { + t.Fatalf("a deployed app was sent to the removed-app unit %s", got) + } +} diff --git a/controller/internal/backup/restore_points.go b/controller/internal/backup/restore_points.go index d2bf572..7c63f71 100644 --- a/controller/internal/backup/restore_points.go +++ b/controller/internal/backup/restore_points.go @@ -38,14 +38,18 @@ func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, fo if m.stackProvider == nil { 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. + // 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. + // R-690: "removed" is isStackDeployed, not GetStackComposePath — the latter is true for every + // catalog app on a box, so the picker offered 0 copies for a removed app on a data drive. + if !m.isStackDeployed(stackName) { if u, found := m.RemovedAppUnitFor(stackName); found { return []RestorePoint{{Time: u.Time, ShortID: restorePointShortID, Tier: 1, DriveLabel: u.DriveLabel}}, true } + } + if _, ok := m.stackProvider.GetStackComposePath(stackName); !ok { return nil, false } diff --git a/controller/internal/backup/restore_unit.go b/controller/internal/backup/restore_unit.go index d97062f..9271903 100644 --- a/controller/internal/backup/restore_unit.go +++ b/controller/internal/backup/restore_unit.go @@ -192,12 +192,16 @@ func (m *Manager) RestoreFromRecoveryUnit(stackName string) (UnitRestoreResult, // backups/primary/ 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. +// +// R-690 (2026-09-25): "removed" is asked with isStackDeployed — the removed-app list's OWN predicate. +// It used to be GetStackComposePath's ok, which in production is true for EVERY catalog app (every +// template is a stack), so this branch never ran on a box: nextcloud's unit on a data drive was +// missed, the restore took the volume-only fallback, and the app came back with no env and no +// database. Pinned by TestR690_RemovedUnitFoundWhenTheStackStillExists (production-shaped provider). 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 - } + if m.stackProvider != nil && !m.isStackDeployed(stackName) { + if u, found := m.RemovedAppUnitFor(stackName); found { + return u.UnitDir } } return RecoveryUnitPath(m.namespaceRoot(m.GetAppDrivePath(stackName)), stackName) diff --git a/controller/internal/fillwatch/fillwatch.go b/controller/internal/fillwatch/fillwatch.go index a677beb..54aaacc 100644 --- a/controller/internal/fillwatch/fillwatch.go +++ b/controller/internal/fillwatch/fillwatch.go @@ -142,6 +142,9 @@ type Watcher struct { usage func(path string) *Usage // notify pushes the customer event. Nil-safe. notify func(Event) + // extra appends a sentence to the warning for one target — the kept folders on it the household + // can delete (`09` §3 decision 36). Nil-safe; "" appends nothing. + extra func(Target) string mu sync.Mutex bands map[string]Band @@ -164,6 +167,9 @@ func New(statePath string, logger *log.Logger, targets func() []Target, usage fu // SetNotify wires the customer event push. INIT-ONLY — call once at startup. func (w *Watcher) SetNotify(fn func(Event)) { w.notify = fn } +// SetExtra wires the kept-data sentence (main.go). INIT-ONLY. +func (w *Watcher) SetExtra(fn func(Target) string) { w.extra = fn } + // classify decides the band from a reading AND the previous band, which is what makes the hysteresis // work: between the clear and warn thresholds the previous band is HELD rather than recomputed. // @@ -231,6 +237,11 @@ func (w *Watcher) Check() error { } msg := Message(t, *u, next) + if w.extra != nil { + if x := w.extra(t); x != "" { + msg += " " + x + } + } w.logger.Printf("[WARN] [fillwatch] %s (%q): %s → %s — %.0f%% used, %.1f GB free of %.1f GB; notifying the customer", t.Path, t.Label, prev, next, u.UsedPercent, u.AvailGB, u.TotalGB) emitted++ diff --git a/controller/internal/fillwatch/kept_extra_test.go b/controller/internal/fillwatch/kept_extra_test.go new file mode 100644 index 0000000..5faa6ad --- /dev/null +++ b/controller/internal/fillwatch/kept_extra_test.go @@ -0,0 +1,30 @@ +package fillwatch + +import ( + "io" + "log" + "path/filepath" + "strings" + "testing" +) + +// `09` §3 decision 36 — the drive-full warning names the kept folders on that drive (and only there). +func TestFillwatch_ExtraSentenceRidesTheWarning(t *testing.T) { + var got []Event + w := New(filepath.Join(t.TempDir(), "s.json"), log.New(io.Discard, "", 0), + func() []Target { return []Target{{Path: "/mnt/d", Label: "HDD"}} }, + func(string) *Usage { return &Usage{UsedPercent: 97, AvailGB: 3, UsedGB: 97, TotalGB: 100} }) + w.SetNotify(func(e Event) { got = append(got, e) }) + w.SetExtra(func(tg Target) string { + if tg.Path == "/mnt/d" { + return "KEPT: Nextcloud (126.0 MB)." + } + return "" + }) + if err := w.Check(); err != nil { + t.Fatal(err) + } + if len(got) != 1 || !strings.HasSuffix(got[0].Message, " KEPT: Nextcloud (126.0 MB).") { + t.Fatalf("events = %+v", got) + } +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 2e7442f..1d986b1 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2417,5 +2417,42 @@ "backup.tier.no_space_unknown": "The full system backup does not fit on the disk. Keeping fewer old backups, or a bigger disk, fixes it.", "update.phase.converting": "Converting the database", "err.stacks.update_convert_space": "Converting the database of %s needs %s of free space, and only %s is free. Nothing changed.", - "err.stacks.update_engine_no_test": "This step would move the main version of the database of %s, but it has no test. Nothing changed." + "err.stacks.update_engine_no_test": "This step would move the main version of the database of %s, but it has no test. Nothing changed.", + "kept.choice.title": "This app's old data is still here", + "kept.choice.body": "The old data of %s (%s, from %s) is still on the drive. What should happen to it?", + "kept.choice.use.label": "Use my kept data", + "kept.choice.use.desc": "We load the database from the backup of %s and start the app with the old files. Changes made after that time can be missing.", + "kept.choice.use_off": "There is no backup of the database, so the app cannot load the old files. You can look at the files under Kept data.", + "kept.choice.fresh.label": "Start fresh", + "kept.choice.fresh.desc": "The old data moves to a folder marked with today's date. Nothing is deleted.", + "kept.not_backed_up": "This is not backed up.", + "kept.delete.confirm": "The kept data of %s (%s) is deleted for good. This cannot be undone.", + "kept.delete.type": "To delete it, type: %s", + "kept.delete.mismatch": "The name you typed does not match (%s), so nothing was deleted.", + "kept.deleted": "The kept data of %s is deleted.", + "kept.page.title": "Kept data", + "kept.page.back": "Back", + "kept.page.intro": "Data that removed apps left on the drives. You can look at it, load it into a new install, or delete it. The server never deletes any of it by itself.", + "kept.page.link_hint": "Data that removed apps left on the drives", + "kept.list.backup": "Can be loaded from:", + "kept.list.installed": "The app is installed again.", + "kept.list.empty": "There is no kept data.", + "kept.backup.own": "its own backup, %s", + "kept.backup.second": "the second drive, %s", + "kept.backup.with": "the backup kept with it, %s", + "kept.backup.none": "no backup — the files only", + "kept.action.load": "Load", + "kept.action.look": "Look", + "kept.action.delete": "Delete", + "kept.action.delete_final": "Delete for good", + "kept.load.started": "Loading the kept data of %s has started.", + "kept.load.done": "%s runs with its kept data.", + "kept.load.failed": "Loading %s did not work: %v. The kept files stayed where they are.", + "kept.load.installed": "%s is installed already, so this cannot be loaded now.", + "kept.fb_source": "Kept data", + "kept.diskwarn": "Kept data on this storage that you can delete on the Kept data page: %s.", + "err.kept.not_listed": "This is not kept data, so nothing was touched.", + "err.kept.occupied": "The app's folder already holds data, so the kept files were not put back. Nothing changed.", + "api.kept.busy": "A backup or a restore is running now. Try again when it has finished.", + "page.title.kept_data": "Kept data" } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 84d60e0..c75ecb7 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2405,5 +2405,42 @@ "backup.tier.no_space_unknown": "A teljes rendszermentés nem fér el a lemezen. Kevesebb régi mentés megtartása vagy nagyobb lemez segít.", "update.phase.converting": "Adatbázis átalakítása", "err.stacks.update_convert_space": "A(z) %s adatbázisának átalakításához %s szabad hely kell, de csak %s van. Nem változott semmi.", - "err.stacks.update_engine_no_test": "Ez a lépés a(z) %s adatbázisának fő verzióját váltaná, de nincs róla próba. Nem változott semmi." + "err.stacks.update_engine_no_test": "Ez a lépés a(z) %s adatbázisának fő verzióját váltaná, de nincs róla próba. Nem változott semmi.", + "kept.choice.title": "Ennek az alkalmazásnak megvannak a régi adatai", + "kept.choice.body": "A(z) %s korábbi adatai (%s, %s) még a lemezen vannak. Mit csináljunk velük?", + "kept.choice.use.label": "A megőrzött adataimat használom", + "kept.choice.use.desc": "Az adatbázist a %s-i mentésből töltjük vissza, és a régi fájlokkal indítjuk az alkalmazást. Ami ezután változott, hiányozhat.", + "kept.choice.use_off": "Az adatbázisról nincs mentés, ezért az alkalmazás nem tudja betölteni a régi fájlokat. A fájlokat megnézheted a Megőrzött adatok között.", + "kept.choice.fresh.label": "Tiszta lappal kezdem", + "kept.choice.fresh.desc": "A régi adatok egy mai dátummal jelölt mappába kerülnek. Nem törlünk semmit.", + "kept.not_backed_up": "Erről nem készül mentés.", + "kept.delete.confirm": "A(z) %s megőrzött adatai (%s) véglegesen törlődnek. Ezt nem lehet visszacsinálni.", + "kept.delete.type": "A törléshez írd be: %s", + "kept.delete.mismatch": "A beírt név nem egyezik (%s), ezért nem töröltünk semmit.", + "kept.deleted": "A(z) %s megőrzött adatai törölve.", + "kept.page.title": "Megőrzött adatok", + "kept.page.back": "Vissza", + "kept.page.intro": "Az eltávolított alkalmazások lemezen maradt adatai. Megnézheted őket, betöltheted egy új telepítésbe, vagy törölheted. Magától a szerver soha nem töröl közülük semmit.", + "kept.page.link_hint": "Eltávolított alkalmazások lemezen maradt adatai", + "kept.list.backup": "Visszatölthető innen:", + "kept.list.installed": "Az alkalmazás újra telepítve van.", + "kept.list.empty": "Nincs megőrzött adat.", + "kept.backup.own": "saját mentés, %s", + "kept.backup.second": "második meghajtó, %s", + "kept.backup.with": "a vele megőrzött mentés, %s", + "kept.backup.none": "nincs mentés — csak a fájlok", + "kept.action.load": "Betöltés", + "kept.action.look": "Megnézem", + "kept.action.delete": "Törlés", + "kept.action.delete_final": "Végleges törlés", + "kept.load.started": "A(z) %s megőrzött adatainak betöltése elindult.", + "kept.load.done": "A(z) %s a megőrzött adataival fut.", + "kept.load.failed": "A(z) %s betöltése nem sikerült: %v. A megőrzött fájlok a helyükön maradtak.", + "kept.load.installed": "A(z) %s már telepítve van, ezért ez nem tölthető be most.", + "kept.fb_source": "Megőrzött adatok", + "kept.diskwarn": "Megőrzött adatok ezen a tárolón, amelyeket te törölhetsz a Megőrzött adatok oldalon: %s.", + "err.kept.not_listed": "Ez nem megőrzött adat, ezért nem nyúltunk hozzá.", + "err.kept.occupied": "Az alkalmazás mappájában már vannak adatok, ezért a megőrzött fájlokat nem tettük vissza. Nem változott semmi.", + "api.kept.busy": "Most egy mentés vagy visszaállítás fut. Ha befejeződött, próbáld újra.", + "page.title.kept_data": "Megőrzött adatok" } diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index bcbe8ce..1b2b0db 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -34,6 +34,9 @@ const ( // template is /files/{encodeURIComponent(name)}/... (SPIKE P2). Accents round-trip correctly — // the spike verified an accented, spaced and ampersand'd source name end to end. FileBrowserImportLabel = "Beolvasás" + // FileBrowserKeptMount is the in-container folder NAME of the read-only „Megőrzött adatok" source + // (`09` §3 decision 36): each kept item is its own `:ro` bind under /srv//. + FileBrowserKeptMount = "megorzott" // SambaImage is our own pinned LAN-sharing image (R-7 slice 1). Built by // controller/scripts/build-samba-image.sh from controller/infra-images/samba/. NEVER :latest. SambaImage = "gitea.dooplex.hu/admin/felhom-samba:1.1.0" @@ -266,6 +269,12 @@ http: // storage path (each a named sidebar entry). Empty paths → a single default /srv source. Ported // verbatim from internal/web/handlers.go. func RenderFileBrowserConfig(paths []settings.StoragePath, importSource bool) string { + return RenderFileBrowserConfigKept(paths, importSource, "") +} + +// RenderFileBrowserConfigKept is RenderFileBrowserConfig plus the read-only kept-data source, LAST, +// named keptLabel ("" = no such source: nothing is kept, or the caller predates it). +func RenderFileBrowserConfigKept(paths []settings.StoragePath, importSource bool, keptLabel string) string { var sources string // The canonical drop-zone (R-75) is FIRST and is NOT a registered storage path — it is a separate // bind of /userdata/import. Separate rather than nested inside a drive source: @@ -289,6 +298,10 @@ func RenderFileBrowserConfig(paths []settings.StoragePath, importSource bool) st } } + if keptLabel != "" { + sources += fmt.Sprintf(" - path: %q\n name: %q\n config:\n defaultEnabled: true\n", + "/srv/"+FileBrowserKeptMount, keptLabel) + } return fmt.Sprintf(`# FileBrowser Quantum — managed by felhom-controller # WARNING: This file is auto-generated. Manual edits will be overwritten. diff --git a/controller/internal/infra/kept_source_test.go b/controller/internal/infra/kept_source_test.go new file mode 100644 index 0000000..e370db2 --- /dev/null +++ b/controller/internal/infra/kept_source_test.go @@ -0,0 +1,17 @@ +package infra + +import ( + "strings" + "testing" +) + +// `09` §3 decision 36 — the kept-data source appears only when named, at /srv/megorzott. +func TestFileBrowser_KeptSourceOnlyWhenNamed(t *testing.T) { + if s := RenderFileBrowserConfigKept(nil, false, ""); strings.Contains(s, FileBrowserKeptMount) { + t.Fatal("a kept source rendered with no label") + } + s := RenderFileBrowserConfigKept(nil, false, "Megőrzött adatok") + if !strings.Contains(s, `path: "/srv/`+FileBrowserKeptMount+`"`) || !strings.Contains(s, `name: "Megőrzött adatok"`) { + t.Fatalf("kept source missing:\n%s", s) + } +} diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 40b7ef6..7c70e64 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -209,6 +209,9 @@ func ProtectedHDDPaths(hddPath string) map[string]bool { filepath.Join(hddPath, "backups"): true, filepath.Join(hddPath, "media"): true, filepath.Join(hddPath, "Dokumentumok"): true, + // `09` §3 decision 36: the dated kept folders. Never removed by an app's removal — only the + // household's Delete on the kept-data list removes one (kept.go rule 4). + filepath.Join(hddPath, KeptDirName): true, // Legacy pre-Model-A double-nest location; kept protected so any leftover data there is // never wiped by a removal. filepath.Join(hddPath, felhomDataDir): true, diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index af97cc8..272a1d0 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -191,8 +191,18 @@ type InstalledImage struct { type DeployRequest struct { StackName string `json:"stack_name"` Values map[string]string `json:"values"` // env_var -> user-provided value + // KeptData is the household's answer when the app's drive folder already holds old data + // (`09` §3 decision 36): KeptChoiceFresh here; KeptChoiceUse is carried out by the API as a load + // from a backup and never reaches DeployStack. "" = no choice was made — refused when old data exists. + KeptData string `json:"kept_data,omitempty"` } +// Kept-data choices at install (`09` §3 decision 36). +const ( + KeptChoiceUse = "use" + KeptChoiceFresh = "fresh" +) + // DeployStack handles first-time deployment of an app. // Returns a warning message (empty if none) and an error if deployment is blocked. // 1. Check available memory against app requirements @@ -358,6 +368,34 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { } } + // `09` §3 decision 36 (R-657): an install NEVER runs into an app's old data silently. When the app's + // private drive folder already holds something, the household chooses: „start fresh" moves it into a + // dated kept folder (a rename on the same drive — nothing is deleted); „use my kept data" is a load + // from a backup, which the API performs instead of an install. No choice → refused, nothing moved. + // Pinned by TestKept_DeployRefusesOverOldDataWithoutAChoice. + if old := OldAppDataPaths(stack.ComposePath, env["HDD_PATH"]); len(old) > 0 { + switch req.KeptData { + case KeptChoiceFresh: + unit := "" + if m.keptUnitFn != nil { + unit = m.keptUnitFn(req.StackName, env["HDD_PATH"]) + } + kept, err := m.KeepAside(req.StackName, env["HDD_PATH"], old, unit, time.Now()) + if err != nil { + clearDeploying() + return "", fmt.Errorf("start fresh: %w", err) + } + m.logger.Printf("[INFO] [stacks] Deploy %s: start fresh — the old data (%v) is kept in %s", req.StackName, old, kept) + case "": + clearDeploying() + m.logger.Printf("[WARN] [stacks] Deploy %s REFUSED: the drive already holds its old data %v and no choice was made", req.StackName, old) + return "", util.KindErrorf(ErrKeptDataChoice, "the drive already holds %s's old data (%s): choose use or fresh", req.StackName, strings.Join(old, ", ")) + default: + clearDeploying() + return "", util.KindErrorf(ErrKeptDataChoice, "kept_data %q is not an install choice here (use is a load from a backup)", req.KeptData) + } + } + // Save app.yaml. // CTRL-T2-1: persist the env now, but mark the ON-DISK state Deployed:false // until `docker compose up -d` actually succeeds (done in runComposeDeploy). diff --git a/controller/internal/stacks/deploy_errors.go b/controller/internal/stacks/deploy_errors.go index fe7a8a1..85be048 100644 --- a/controller/internal/stacks/deploy_errors.go +++ b/controller/internal/stacks/deploy_errors.go @@ -22,6 +22,9 @@ var ( ErrPathMissing = errors.New("path field does not exist") // ErrNotEnoughMemory — the memory verdict refused the deploy (API: 400). ErrNotEnoughMemory = errors.New("not enough memory") + // ErrKeptDataChoice — the app's drive folder already holds old data and the install named neither + // „use my kept data" nor „start fresh" (`09` §3 decision 36; API: 409 with code kept_data_choice). + ErrKeptDataChoice = errors.New("kept data: a choice is needed") // ErrStackDeploying — R-634 (v0.265.0): a stop or start was asked of a stack whose deploy is still // running. Refused, because a `compose down` or a second `compose up -d` in the middle of the // deploy's own `up` makes BOTH fail, and the deploy then records „not deployed" over whatever diff --git a/controller/internal/stacks/kept.go b/controller/internal/stacks/kept.go new file mode 100644 index 0000000..1122fc0 --- /dev/null +++ b/controller/internal/stacks/kept.go @@ -0,0 +1,557 @@ +package stacks + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/util" +) + +// ── Kept data (`09` §3 decision 36, operator ruling 2026-09-25 evening) ──────────────────────────── +// +// WHAT IT REPLACES. „Remove the app, keep my data" left the app's private drive folder +// (`/appdata/`) behind, and a later install of the same app ran straight into it (R-657): +// measured 2026-09-23 as an install loop, and 2026-09-25 as a clean install whose database simply knows +// nothing of the old files. Nothing listed the folder, nothing opened it, nothing deleted it. +// +// THE RULING. A reinstall over kept data asks: "use my kept data" (the database from a backup, with the +// kept files) or "start fresh" (the kept files MOVE to a dated folder; nothing is deleted). And kept data +// is never a dead end: the household can see it (read only), load it later, and delete it. +// +// FOUR RULES, each a guard with a test: +// +// 1. ONLY `/appdata/<…>` IS "OLD DATA". An app's bind under userdata/ or media/ is the household's +// own shared files (a music library, a photo folder) — never moved, never listed here. Pinned by +// TestKept_OnlyAppdataBindsAreOldData. +// 2. "START FRESH" IS A RENAME ON THE SAME DRIVE — never a copy, never across drives. A rename that +// fails with EXDEV (or anything else) puts back what it already moved and refuses. Pinned by +// TestKept_KeepAsideIsARenameAndRollsBack. +// 3. THE KEPT FOLDER IS `/kept///` — beside appdata/ and userdata/, inside neither: +// no app bind reaches it, FileBrowser and Samba serve only userdata/, and no backup leg reads it (the +// Tier-2 and off-site legs read userdata/ and the declared binds of DEPLOYED apps). It is in +// ProtectedHDDPaths. The page says it is not backed up. +// 4. THE BOX NEVER DELETES KEPT DATA BY ITSELF (D3 is open). DeleteKept is reachable only from the +// household's typed confirmation, and refuses any path that is not a listed kept item. Pinned by +// TestKept_DeleteRefusesAnythingNotListed. + +// KeptDirName is the folder at a drive's namespace root that holds the dated kept folders. +const KeptDirName = "kept" + +// keptMarkerFile is written LAST into a dated kept folder: what was moved, from where, and when. +const keptMarkerFile = ".felhom-kept.json" + +// keptUnitDir is where a start-fresh moves the removed app's recovery unit, so the kept files keep the +// database copy that belongs to them (the next backup of the fresh install would overwrite it in place). +const keptUnitDir = "unit" + +// Kept item kinds. +const ( + KeptKindDated = "dated" // a start-fresh folder under /kept// + KeptKindLeftover = "leftover" // /appdata/ that no installed app binds (a plain keep-data remove) +) + +// KeptMarker is the record inside a dated kept folder. +type KeptMarker struct { + App string `json:"app"` + MovedAt string `json:"moved_at"` // RFC3339 UTC + Paths []string `json:"paths"` // relative to the drive root, as they were (e.g. appdata/nextcloud) + Unit bool `json:"unit,omitempty"` // a recovery unit was moved in with them (/unit) + Drive string `json:"drive,omitempty"` // the drive root they came from +} + +// KeptItem is one row of the „Megőrzött adatok" list. +type KeptItem struct { + App string `json:"app"` // the catalog app it belongs to ("" = unknown) + DisplayName string `json:"display_name"` // the app's name, else the folder name + Path string `json:"path"` // the kept folder (absolute) + Drive string `json:"drive"` // the drive root it sits on + Kind string `json:"kind"` + Date time.Time `json:"date"` + SizeBytes int64 `json:"size_bytes"` + // UnitDir is the recovery unit moved in with a dated folder ("" none) — its database copy. + UnitDir string `json:"unit_dir,omitempty"` + // Marker is the dated folder's record (nil for a leftover). + Marker *KeptMarker `json:"marker,omitempty"` +} + +// Errors, born as bundle keys. +var ( + ErrKeptNotListed = util.MsgError("err.kept.not_listed") + ErrKeptOccupied = util.MsgError("err.kept.occupied") +) + +// OldAppDataPaths is rule 1 as a pure function: the app's binds under `/appdata/` (never appdata +// itself) that exist and hold at least one entry. +func OldAppDataPaths(composePath, hddPath string) []string { + if hddPath == "" { + return nil + } + appdata := filepath.Join(filepath.Clean(hddPath), "appdata") + string(filepath.Separator) + var out []string + for _, p := range ParseComposeHDDMounts(composePath, hddPath) { + p = filepath.Clean(p) + if !strings.HasPrefix(p, appdata) { + continue + } + if dirHasEntries(p) { + out = append(out, p) + } + } + sort.Strings(out) + return out +} + +func dirHasEntries(p string) bool { + f, err := os.Open(p) + if err != nil { + return false + } + defer f.Close() + names, _ := f.Readdirnames(1) + return len(names) > 0 +} + +// OldAppData is OldAppDataPaths for an app about to be installed on hddPath (its catalog definition). +func (m *Manager) OldAppData(name, hddPath string) []string { + st, ok := m.GetStack(name) + if !ok || st.ComposePath == "" { + return nil + } + return OldAppDataPaths(st.ComposePath, hddPath) +} + +// KeptDirFor names a new dated kept folder. The date is the box's local day and time, so the household +// recognises „today" in the name. +func KeptDirFor(hddPath, app string, now time.Time) string { + return filepath.Join(filepath.Clean(hddPath), KeptDirName, app, now.In(getTimezone()).Format("2006-01-02_150405")) +} + +// renameFn is the rename seam (tests inject EXDEV); production is os.Rename. +var renameFn = os.Rename + +// KeepAside is "start fresh": it MOVES each old-data folder (and, when unitDir is on the same drive, the +// removed app's recovery unit) into a new dated kept folder, keeping each one's path relative to the drive. +// A failed move puts back everything already moved and returns the error — nothing is copied, nothing is +// deleted. Returns the kept folder. +func (m *Manager) KeepAside(name, hddPath string, paths []string, unitDir string, now time.Time) (string, error) { + drive := filepath.Clean(hddPath) + if len(paths) == 0 { + return "", fmt.Errorf("nothing to keep aside for %s", name) + } + dest := KeptDirFor(drive, name, now) + if _, err := os.Stat(dest); err == nil { + return "", fmt.Errorf("the kept folder %s already exists", dest) + } + if err := os.MkdirAll(dest, 0o755); err != nil { + return "", fmt.Errorf("creating the kept folder: %w", err) + } + type moved struct{ from, to string } + var done []moved + undo := func() { + for i := len(done) - 1; i >= 0; i-- { + if err := renameFn(done[i].to, done[i].from); err != nil { + m.logger.Printf("[ERROR] [stacks] kept %s: putting %s back to %s FAILED: %v", name, done[i].to, done[i].from, err) + } + } + removeEmptyDirs(dest) // os.Remove only: a folder a failed move-back left data in STAYS + } + marker := KeptMarker{App: name, MovedAt: now.UTC().Format(time.RFC3339), Drive: drive} + for _, p := range paths { + p = filepath.Clean(p) + rel, err := filepath.Rel(drive, p) + if err != nil || strings.HasPrefix(rel, "..") || !strings.HasPrefix(rel, "appdata"+string(filepath.Separator)) { + undo() + return "", fmt.Errorf("refusing to keep aside %s: not under %s/appdata", p, drive) + } + to := filepath.Join(dest, rel) + if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { + undo() + return "", err + } + if err := renameFn(p, to); err != nil { + undo() + if errors.Is(err, syscall.EXDEV) { + return "", fmt.Errorf("moving %s would cross drives — refused, nothing moved: %w", p, err) + } + return "", fmt.Errorf("moving %s: %w — nothing moved", p, err) + } + done = append(done, moved{p, to}) + marker.Paths = append(marker.Paths, rel) + m.logger.Printf("[INFO] [stacks] kept %s: moved %s → %s (start fresh)", name, p, to) + } + if unitDir != "" { + to := filepath.Join(dest, keptUnitDir) + if err := renameFn(unitDir, to); err != nil { + // The files are kept either way; without the unit a later Load has no database copy, which + // the list then says. Not a reason to undo the household's choice. + m.logger.Printf("[WARN] [stacks] kept %s: the recovery unit %s could not move in with the files (%v) — the kept folder has no database copy", name, unitDir, err) + } else { + marker.Unit = true + m.logger.Printf("[INFO] [stacks] kept %s: moved the recovery unit %s → %s", name, unitDir, to) + } + } + b, _ := json.MarshalIndent(marker, "", " ") + if err := os.WriteFile(filepath.Join(dest, keptMarkerFile), b, 0o644); err != nil { + m.logger.Printf("[WARN] [stacks] kept %s: could not write the marker in %s: %v — listed without it", name, dest, err) + } + return dest, nil +} + +func readKeptMarker(dir string) *KeptMarker { + b, err := os.ReadFile(filepath.Join(dir, keptMarkerFile)) + if err != nil { + return nil + } + var mk KeptMarker + if json.Unmarshal(b, &mk) != nil { + return nil + } + return &mk +} + +// liveDriveBinds is every drive folder an INSTALLED app binds — never kept data. +func (m *Manager) liveDriveBinds() []string { + var out []string + for _, st := range m.GetStacks() { + if !st.Deployed || st.AppConfig == nil { + continue + } + hdd := strings.TrimSpace(st.AppConfig.Env["HDD_PATH"]) + if hdd == "" { + continue + } + for _, p := range ParseComposeHDDMounts(st.ComposePath, hdd) { + out = append(out, filepath.Clean(p)) + } + } + return out +} + +func overlaps(a, b string) bool { + sep := string(filepath.Separator) + return a == b || strings.HasPrefix(a, b+sep) || strings.HasPrefix(b, a+sep) +} + +// ownerOf names the catalog app whose definition binds drive-relative folder rel (e.g. appdata/paperless +// → paperless-ngx). A stack named like the folder wins; "" when none declares it. +func (m *Manager) ownerOf(drive, abs string) (string, string) { + var cands []Stack + for _, st := range m.GetStacks() { + if st.ComposePath == "" { + continue + } + for _, p := range ParseComposeHDDMounts(st.ComposePath, drive) { + if filepath.Clean(p) == abs { + cands = append(cands, st) + break + } + } + } + if len(cands) == 0 { + return "", "" + } + pick := cands[0] + for _, c := range cands { + if c.Name == filepath.Base(abs) { + pick = c + } + } + dn := pick.Meta.DisplayName + if dn == "" { + dn = pick.Name + } + return pick.Name, dn +} + +// sizeFn is the size seam (production walks the tree). +var sizeFn = getDirSizeBytes + +// ListKept lists every kept item on the given drive roots: the dated folders under /kept//, +// and each /appdata/ that holds something and that no installed app binds. Sorted newest first. +func (m *Manager) ListKept(drives []string) []KeptItem { + live := m.liveDriveBinds() + var out []KeptItem + seen := map[string]bool{} + for _, d := range drives { + d = filepath.Clean(d) + if d == "" || seen[d] { + continue + } + seen[d] = true + apps, _ := os.ReadDir(filepath.Join(d, KeptDirName)) + for _, a := range apps { + if !a.IsDir() { + continue + } + stamps, _ := os.ReadDir(filepath.Join(d, KeptDirName, a.Name())) + for _, s := range stamps { + if !s.IsDir() { + continue + } + dir := filepath.Join(d, KeptDirName, a.Name(), s.Name()) + it := KeptItem{App: a.Name(), DisplayName: a.Name(), Path: dir, Drive: d, Kind: KeptKindDated, + Marker: readKeptMarker(dir), SizeBytes: sizeFn(dir)} + if st, ok := m.GetStack(a.Name()); ok && st.Meta.DisplayName != "" { + it.DisplayName = st.Meta.DisplayName + } + if it.Marker != nil { + if t, err := time.Parse(time.RFC3339, it.Marker.MovedAt); err == nil { + it.Date = t + } + if it.Marker.Unit { + if _, err := os.Stat(filepath.Join(dir, keptUnitDir)); err == nil { + it.UnitDir = filepath.Join(dir, keptUnitDir) + } + } + } + if it.Date.IsZero() { + if fi, err := os.Stat(dir); err == nil { + it.Date = fi.ModTime() + } + } + out = append(out, it) + } + } + ents, _ := os.ReadDir(filepath.Join(d, "appdata")) + for _, e := range ents { + if !e.IsDir() { + continue + } + abs := filepath.Join(d, "appdata", e.Name()) + isLive := false + for _, l := range live { + if overlaps(abs, l) { + isLive = true + break + } + } + if isLive || !dirHasEntries(abs) { + continue + } + app, dn := m.ownerOf(d, abs) + if dn == "" { + dn = e.Name() + } + it := KeptItem{App: app, DisplayName: dn, Path: abs, Drive: d, Kind: KeptKindLeftover, SizeBytes: sizeFn(abs)} + if fi, err := os.Stat(abs); err == nil { + it.Date = fi.ModTime() + } + out = append(out, it) + } + } + sort.Slice(out, func(i, j int) bool { + if !out[i].Date.Equal(out[j].Date) { + return out[i].Date.After(out[j].Date) + } + return out[i].Path < out[j].Path + }) + return out +} + +// FindKept returns the listed item at exactly path — the only way an action names a kept item. +func (m *Manager) FindKept(drives []string, path string) (KeptItem, bool) { + clean := filepath.Clean(path) + for _, it := range m.ListKept(drives) { + if it.Path == clean { + return it, true + } + } + return KeptItem{}, false +} + +// DeleteKept is the household's Delete, and the ONLY deletion of kept data anywhere in the product +// (rule 4). It refuses a path that is not a listed kept item — a live app's folder is never listed. +func (m *Manager) DeleteKept(drives []string, path string) (KeptItem, error) { + it, ok := m.FindKept(drives, path) + if !ok { + m.logger.Printf("[WARN] [stacks] kept: delete REFUSED for %s — not a listed kept item", path) + return KeptItem{}, ErrKeptNotListed + } + if err := os.RemoveAll(it.Path); err != nil { + return it, fmt.Errorf("deleting %s: %w", it.Path, err) + } + m.logger.Printf("[INFO] [stacks] kept: DELETED %s (%s, %d bytes) — the household's typed confirmation", it.Path, it.DisplayName, it.SizeBytes) + if it.Kind == KeptKindDated { + _ = os.Remove(filepath.Dir(it.Path)) // the per-app folder, only when now empty + } + return it, nil +} + +// RestoreKeptFiles puts a dated item's files back where they were (a rename, the reverse of KeepAside) +// before a Load; a leftover item is already in place. It refuses when any destination holds something — +// that would be two installs' data in one folder. Returns the unit to load from ("" none). +func (m *Manager) RestoreKeptFiles(it KeptItem) (string, error) { + if it.Kind != KeptKindDated { + return "", nil + } + if it.Marker == nil || len(it.Marker.Paths) == 0 { + return "", fmt.Errorf("the kept folder %s has no record of where its files came from", it.Path) + } + for _, rel := range it.Marker.Paths { + if dirHasEntries(filepath.Join(it.Drive, rel)) { + return "", ErrKeptOccupied + } + } + var done []string + for _, rel := range it.Marker.Paths { + from, to := filepath.Join(it.Path, rel), filepath.Join(it.Drive, rel) + _ = os.Remove(to) // an EMPTY leftover directory only (checked above) + if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { + return "", err + } + if err := renameFn(from, to); err != nil { + for _, r := range done { + _ = renameFn(filepath.Join(it.Drive, r), filepath.Join(it.Path, r)) + } + return "", fmt.Errorf("moving %s back: %w — nothing moved", from, err) + } + done = append(done, rel) + m.logger.Printf("[INFO] [stacks] kept %s: moved %s back → %s (load)", it.App, from, to) + } + return it.UnitDir, nil +} + +// FinishKeptLoad runs after a successful Load of a dated item: the unit it carried becomes the app's own +// unit again when that place is free (unitHome = backups/primary/ on the drive), and the kept folder +// is removed only when nothing but empty directories and its marker remain — no data is deleted here. +func (m *Manager) FinishKeptLoad(it KeptItem, unitHome string) { + if it.Kind != KeptKindDated { + return + } + if it.UnitDir != "" && unitHome != "" { + if _, err := os.Stat(unitHome); os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(unitHome), 0o755); err == nil { + if err := renameFn(it.UnitDir, unitHome); err == nil { + m.logger.Printf("[INFO] [stacks] kept %s: the loaded unit is the app's own again → %s", it.App, unitHome) + } + } + } else { + m.logger.Printf("[INFO] [stacks] kept %s: %s already holds a unit — the loaded one stays in %s (listed; the household decides)", it.App, unitHome, it.UnitDir) + } + } + if keptHoldsOnlyEmptyDirs(it.Path) { + _ = os.Remove(filepath.Join(it.Path, keptMarkerFile)) + removeEmptyDirs(it.Path) + if _, err := os.Stat(it.Path); os.IsNotExist(err) { + _ = os.Remove(filepath.Dir(it.Path)) + m.logger.Printf("[INFO] [stacks] kept %s: %s is empty after the load — removed from the list", it.App, it.Path) + } + } +} + +// keptHoldsOnlyEmptyDirs: true when the tree holds no file other than the marker. +func keptHoldsOnlyEmptyDirs(root string) bool { + only := true + _ = filepath.Walk(root, func(p string, fi os.FileInfo, err error) error { + if err != nil { + only = false + return filepath.SkipDir + } + if !fi.IsDir() && !(filepath.Dir(p) == root && fi.Name() == keptMarkerFile) { + only = false + return filepath.SkipDir + } + return nil + }) + return only +} + +// RunAfterLoad runs the app's `after_load:` once, after a Load, when the app is running (waits up to +// wait). Logged by name either way; a failure is reported, never retried. +func (m *Manager) RunAfterLoad(name string, wait time.Duration) (bool, error) { + st, ok := m.GetStack(name) + if !ok { + return false, fmt.Errorf("stack %q not found", name) + } + al := st.Meta.AfterLoad + if al == nil || al.Service == "" || len(al.Command) == 0 { + return false, nil + } + deadline := time.Now().Add(wait) + for { + _ = m.RefreshStatus() + if s, ok := m.GetStack(name); ok && (s.State == StateRunning || s.State == StateUnhealthy) { + break + } + if time.Now().After(deadline) { + return true, fmt.Errorf("the app did not start within %s — after_load not run", wait) + } + time.Sleep(5 * time.Second) + } + return true, m.runAfterLoadNow(name) +} + +// runAfterLoadNow runs the declared command once, now (RunAfterLoad has waited for the app). +func (m *Manager) runAfterLoadNow(name string) error { + st, ok := m.GetStack(name) + if !ok || st.Meta.AfterLoad == nil { + return nil + } + al := st.Meta.AfterLoad + dir := filepath.Dir(st.ComposePath) + args := []string{"exec", "-T"} + if al.User != "" { + args = append(args, "-u", al.User) + } + args = append(args, al.Service) + args = append(args, al.Command...) + t0 := time.Now() + out, err := m.afterLoadExec(dir, args...) + m.logger.Printf("[INFO] [stacks] after_load %s: %s %v in %s (err=%v): %s", name, al.Service, al.Command, time.Since(t0).Round(time.Millisecond), err, truncateStr(out, 400)) + return err +} + +// afterLoadExec is the exec seam; production runs compose with the app's env. +func (m *Manager) afterLoadExec(dir string, args ...string) (string, error) { + if m.afterLoadFn != nil { + return m.afterLoadFn(dir, args...) + } + return m.composeExecCustomEnv(dir, m.stackEnv(dir), args...) +} + +var keptTZ *time.Location + +func getTimezone() *time.Location { + if keptTZ == nil { + if loc, err := time.LoadLocation("Europe/Budapest"); err == nil { + keptTZ = loc + } else { + keptTZ = time.UTC + } + } + return keptTZ +} + +// removeEmptyDirs removes root and every directory under it that is EMPTY — never a file, never a +// directory holding one (os.Remove refuses a non-empty directory). Deepest first. +func removeEmptyDirs(root string) { + var dirs []string + _ = filepath.Walk(root, func(p string, fi os.FileInfo, err error) error { + if err == nil && fi.IsDir() { + dirs = append(dirs, p) + } + return nil + }) + for i := len(dirs) - 1; i >= 0; i-- { + _ = os.Remove(dirs[i]) + } +} + +// DirSizeBytes is the size seam's value for one folder (du -sb; 0 when unreadable). +func DirSizeBytes(p string) int64 { return sizeFn(p) } + +// DirModTime is a folder's modification time (zero when unreadable). +func DirModTime(p string) time.Time { + fi, err := os.Stat(p) + if err != nil { + return time.Time{} + } + return fi.ModTime() +} diff --git a/controller/internal/stacks/kept_test.go b/controller/internal/stacks/kept_test.go new file mode 100644 index 0000000..66cbded --- /dev/null +++ b/controller/internal/stacks/kept_test.go @@ -0,0 +1,270 @@ +package stacks + +import ( + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" +) + +// keptManager is a real Manager over a temp stacks dir with one app, "cloudapp", whose compose binds +// its private data (appdata/cloudapp), a household folder (userdata/Photos) and the shared media root. +// Nothing here reaches Docker (R-650): no test calls a path that runs compose. +func keptManager(t *testing.T) (*Manager, string) { + t.Helper() + dir := t.TempDir() + drive := filepath.Join(dir, "drive") + cfg := &config.Config{} + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Paths.SystemDataPath = filepath.Join(dir, "system") + cfg.Stacks.ComposeCommand = "docker compose" + app := filepath.Join(cfg.Paths.StacksDir, "cloudapp") + must(t, os.MkdirAll(app, 0o755)) + compose := "services:\n cloudapp:\n image: busybox\n volumes:\n" + + " - ${HDD_PATH}/appdata/cloudapp:/data\n" + + " - ${HDD_PATH}/userdata/Photos:/photos\n" + + " - ${HDD_PATH}/media:/media\n" + must(t, os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte(compose), 0o644)) + must(t, os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\ndeploy_fields:\n - env_var: HDD_PATH\n label: Drive\n type: path\n required: true\n"), 0o644)) + m, err := NewManager(cfg, log.New(io.Discard, "", 0)) + must(t, err) + must(t, m.ScanStacks()) + for _, d := range []string{"appdata/cloudapp/user1", "userdata/Photos", "media"} { + must(t, os.MkdirAll(filepath.Join(drive, d), 0o755)) + } + must(t, os.WriteFile(filepath.Join(drive, "appdata/cloudapp/user1/file.txt"), []byte("old"), 0o644)) + must(t, os.WriteFile(filepath.Join(drive, "userdata/Photos/p.jpg"), []byte("photo"), 0o644)) + must(t, os.WriteFile(filepath.Join(drive, "media/song.mp3"), []byte("song"), 0o644)) + return m, drive +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +// Rule 1 — only the app's private appdata bind is "old data"; the household's shared folders never are. +// COMPANION RED-PROOF: drop the appdata prefix check in OldAppDataPaths → the Photos and media folders +// are returned and this fails. +func TestKept_OnlyAppdataBindsAreOldData(t *testing.T) { + m, drive := keptManager(t) + got := m.OldAppData("cloudapp", drive) + want := []string{filepath.Join(drive, "appdata/cloudapp")} + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("old data = %v, want only %v (a household folder must never be moved)", got, want) + } + // An EMPTY private folder is not old data. + must(t, os.RemoveAll(filepath.Join(drive, "appdata/cloudapp/user1"))) + if got := m.OldAppData("cloudapp", drive); len(got) != 0 { + t.Fatalf("an empty folder was called old data: %v", got) + } +} + +// Rule 2 — start fresh is a RENAME (same inode, nothing copied) and a failed move puts back what moved. +// COMPANION RED-PROOF: delete the undo() call on the rename failure → the first folder stays inside the +// kept folder and this fails at "was not put back". +func TestKept_KeepAsideIsARenameAndRollsBack(t *testing.T) { + m, drive := keptManager(t) + src := filepath.Join(drive, "appdata/cloudapp") + second := filepath.Join(drive, "appdata/cloudapp-extra") + must(t, os.MkdirAll(second, 0o755)) + must(t, os.WriteFile(filepath.Join(second, "x"), []byte("x"), 0o644)) + before, err := os.Stat(filepath.Join(src, "user1/file.txt")) + must(t, err) + now := time.Date(2026, 9, 25, 11, 0, 0, 0, time.UTC) + + // A: the second rename fails with EXDEV → both stay where they were, no kept folder, the error says so. + calls := 0 + renameFn = func(a, b string) error { + calls++ + if calls == 2 { + return &os.LinkError{Op: "rename", Old: a, New: b, Err: syscall.EXDEV} + } + return os.Rename(a, b) + } + defer func() { renameFn = os.Rename }() + _, err = m.KeepAside("cloudapp", drive, []string{src, second}, "", now) + if err == nil || !strings.Contains(err.Error(), "cross drives") { + t.Fatalf("want a cross-drive refusal, got %v", err) + } + if _, err := os.Stat(filepath.Join(src, "user1/file.txt")); err != nil { + t.Fatalf("the first folder was not put back after the failed move: %v", err) + } + if _, err := os.Stat(KeptDirFor(drive, "cloudapp", now)); !os.IsNotExist(err) { + t.Fatalf("a failed start-fresh left a kept folder behind (%v)", err) + } + + // B: success — the data is in the kept folder under its drive-relative path, as the SAME file. + renameFn = os.Rename + kept, err := m.KeepAside("cloudapp", drive, []string{src}, "", now) + must(t, err) + after, err := os.Stat(filepath.Join(kept, "appdata/cloudapp/user1/file.txt")) + must(t, err) + if !os.SameFile(before, after) { + t.Fatal("the kept file is not the same inode — it was copied, not moved") + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Fatalf("the old folder is still in place after start fresh (%v)", err) + } + if mk := readKeptMarker(kept); mk == nil || mk.App != "cloudapp" || fmt.Sprint(mk.Paths) != "[appdata/cloudapp]" { + t.Fatalf("marker = %+v", mk) + } + if !strings.HasPrefix(kept, filepath.Join(drive, KeptDirName)+string(filepath.Separator)) || + strings.Contains(kept, "userdata") { + t.Fatalf("kept folder %s is not under /kept (and never under userdata)", kept) + } +} + +// Rule 3 — the kept folder is protected from an app removal's drive clean-up. +// COMPANION RED-PROOF: drop the KeptDirName line from ProtectedHDDPaths → fails. +func TestKept_KeptDirIsProtected(t *testing.T) { + if !ProtectedHDDPaths("/mnt/d")["/mnt/d/"+KeptDirName] { + t.Fatal("/kept is not in ProtectedHDDPaths") + } +} + +// The list: a dated folder (with its unit) and a leftover appdata folder are listed; a live app's +// folder is not; the leftover is named after the catalog app that declares it. +func TestKept_ListShowsKeptAndNeverALiveFolder(t *testing.T) { + m, drive := keptManager(t) + // A leftover of cloudapp (not installed): listed, owner resolved from the catalog definition. + items := m.ListKept([]string{drive}) + if len(items) != 1 || items[0].Kind != KeptKindLeftover || items[0].App != "cloudapp" || items[0].DisplayName != "Cloud App" { + t.Fatalf("leftover listing = %+v", items) + } + // Installed now: its folder is LIVE and disappears from the list. + m.mu.Lock() + s := m.stacks["cloudapp"] + s.Deployed = true + s.AppConfig = &AppConfig{Deployed: true, Env: map[string]string{"HDD_PATH": drive}} + m.mu.Unlock() + if items := m.ListKept([]string{drive}); len(items) != 0 { + t.Fatalf("a live app's folder was listed as kept data: %+v", items) + } + // A dated kept folder with a unit moved in. + unit := filepath.Join(drive, "backups/primary/cloudapp") + must(t, os.MkdirAll(unit, 0o755)) + must(t, os.WriteFile(filepath.Join(unit, "manifest.json"), []byte("{}"), 0o644)) + old := filepath.Join(drive, "appdata/older") + must(t, os.MkdirAll(old, 0o755)) + must(t, os.WriteFile(filepath.Join(old, "f"), []byte("f"), 0o644)) + kept, err := m.KeepAside("cloudapp", drive, []string{old}, unit, time.Now()) + must(t, err) + items = m.ListKept([]string{drive}) + if len(items) != 1 || items[0].Kind != KeptKindDated || items[0].Path != kept || items[0].UnitDir != filepath.Join(kept, keptUnitDir) { + t.Fatalf("dated listing = %+v", items) + } +} + +// Rule 4 — Delete removes ONLY a listed kept item; anything else is refused and untouched. +// COMPANION RED-PROOF: skip the FindKept check in DeleteKept → the live folder is deleted and this fails. +func TestKept_DeleteRefusesAnythingNotListed(t *testing.T) { + m, drive := keptManager(t) + m.mu.Lock() + s := m.stacks["cloudapp"] + s.Deployed = true + s.AppConfig = &AppConfig{Deployed: true, Env: map[string]string{"HDD_PATH": drive}} + m.mu.Unlock() + for _, p := range []string{ + filepath.Join(drive, "appdata/cloudapp"), // a LIVE app's folder + filepath.Join(drive, "userdata/Photos"), // the household's files + drive, // the drive itself + filepath.Join(drive, "appdata/cloudapp/../../userdata"), + } { + if _, err := m.DeleteKept([]string{drive}, p); !errors.Is(err, ErrKeptNotListed) { + t.Fatalf("delete of %s: want ErrKeptNotListed, got %v", p, err) + } + } + for _, p := range []string{"appdata/cloudapp/user1/file.txt", "userdata/Photos/p.jpg", "media/song.mp3"} { + if _, err := os.Stat(filepath.Join(drive, p)); err != nil { + t.Fatalf("%s was touched by a refused delete: %v", p, err) + } + } + // A listed item IS deleted. + left := filepath.Join(drive, "appdata/gone") + must(t, os.MkdirAll(left, 0o755)) + must(t, os.WriteFile(filepath.Join(left, "f"), []byte("f"), 0o644)) + if _, err := m.DeleteKept([]string{drive}, left); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(left); !os.IsNotExist(err) { + t.Fatalf("the listed kept item is still there (%v)", err) + } +} + +// The install never runs into old data silently: no choice (or a wrong one) is refused BEFORE anything +// is written — no app.yaml, the old data in place. +// COMPANION RED-PROOF: delete the OldAppDataPaths block in DeployStack → the deploy saves app.yaml and +// goes on to compose (this test then fails at "no choice was accepted"). +func TestKept_DeployRefusesOverOldDataWithoutAChoice(t *testing.T) { + m, drive := keptManager(t) + for _, choice := range []string{"", "use", "whatever"} { + _, err := m.DeployStack(DeployRequest{StackName: "cloudapp", Values: map[string]string{"HDD_PATH": drive}, KeptData: choice}) + if !errors.Is(err, ErrKeptDataChoice) { + t.Fatalf("choice %q: no choice was accepted — want ErrKeptDataChoice, got %v", choice, err) + } + if _, err := os.Stat(filepath.Join(m.cfg.Paths.StacksDir, "cloudapp", "app.yaml")); !os.IsNotExist(err) { + t.Fatalf("choice %q: app.yaml was written by a refused install", choice) + } + if st, _ := m.GetStack("cloudapp"); st.Deploying || st.Deployed { + t.Fatalf("choice %q: the stack is left Deploying=%v Deployed=%v", choice, st.Deploying, st.Deployed) + } + } + if _, err := os.Stat(filepath.Join(drive, "appdata/cloudapp/user1/file.txt")); err != nil { + t.Fatalf("the old data moved without a choice: %v", err) + } +} + +// Load puts a dated item's files back, refusing when the destination already holds something. +func TestKept_RestoreKeptFilesRefusesAnOccupiedFolder(t *testing.T) { + m, drive := keptManager(t) + src := filepath.Join(drive, "appdata/cloudapp") + kept, err := m.KeepAside("cloudapp", drive, []string{src}, "", time.Now()) + must(t, err) + it, ok := m.FindKept([]string{drive}, kept) + if !ok { + t.Fatal("kept folder not listed") + } + must(t, os.MkdirAll(filepath.Join(src, "new"), 0o755)) // a fresh install wrote here + if _, err := m.RestoreKeptFiles(it); !errors.Is(err, ErrKeptOccupied) { + t.Fatalf("want ErrKeptOccupied over an occupied folder, got %v", err) + } + must(t, os.RemoveAll(src)) + if _, err := m.RestoreKeptFiles(it); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(src, "user1/file.txt")); err != nil { + t.Fatalf("the file did not come back: %v", err) + } + m.FinishKeptLoad(it, "") + if _, err := os.Stat(kept); !os.IsNotExist(err) { + t.Fatalf("the emptied kept folder is still listed (%v)", err) + } +} + +// after_load runs the template's ONE command, as its user, in its service. +func TestKept_AfterLoadRunsTheDeclaredCommand(t *testing.T) { + m, _ := keptManager(t) + var got []string + m.afterLoadFn = func(dir string, args ...string) (string, error) { got = args; return "ok", nil } + m.mu.Lock() + m.stacks["cloudapp"].Meta.AfterLoad = &AfterLoadCommand{Service: "cloudapp", User: "www-data", Command: []string{"php", "occ", "files:scan", "--all"}} + m.stacks["cloudapp"].State = StateRunning + m.mu.Unlock() + if err := m.runAfterLoadNow("cloudapp"); err != nil { + t.Fatal(err) + } + if want := "exec -T -u www-data cloudapp php occ files:scan --all"; strings.Join(got, " ") != want { + t.Fatalf("after_load ran %q, want %q", strings.Join(got, " "), want) + } +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 5c808ac..96ff529 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -284,13 +284,18 @@ type Manager struct { pgConv pgConverter convertFreeFn func(path string) (int64, bool) updateUndoHealthFn func(ctx context.Context, name string, timeout time.Duration, meta *Metadata) (bool, string) - probeRunFn func(t probeTarget) *HealthProbeResult // the health wait's network probe; nil ⇒ runChecks - updateEventSink func(UpdateEvent) // v0.264.0: the notifier; nil ⇒ no events - updateMemoryFn func(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal error, warning string) - updateDiskFreeFn func() (freeGiB float64, ok bool) - updateNowFn func() time.Time - updateJournalMu sync.Mutex - updateResume []string // apps whose update was interrupted after `up`; resumed once guards exist + // afterLoadFn is RunAfterLoad's exec seam (kept.go); nil = compose exec with the app's env. + afterLoadFn func(dir string, args ...string) (string, error) + // keptUnitFn names the removed app's recovery unit on a drive (backup.RemovedAppUnitFor, wired in + // main.go) so a start-fresh moves it in with the files it belongs to. nil = files only. + keptUnitFn func(app, drive string) string + probeRunFn func(t probeTarget) *HealthProbeResult // the health wait's network probe; nil ⇒ runChecks + updateEventSink func(UpdateEvent) // v0.264.0: the notifier; nil ⇒ no events + updateMemoryFn func(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal error, warning string) + updateDiskFreeFn func() (freeGiB float64, ok bool) + updateNowFn func() time.Time + updateJournalMu sync.Mutex + updateResume []string // apps whose update was interrupted after `up`; resumed once guards exist // inspectRestartPolicyFn is the docker-inspect seam for the above; nil in production // (dockerRestartPolicy). Tests inject a scripted lookup and never touch docker. inspectRestartPolicyFn func(containerName string) (string, error) @@ -1711,3 +1716,6 @@ func (m *Manager) getCatalogTemplateSlugs() map[string]bool { func AggregateStateForTest(containers []ContainerInfo) ContainerState { return aggregateState(containers, func(string) string { return "unless-stopped" }) } + +// SetKeptUnitFinder wires the backup side's removed-app unit lookup (INIT-ONLY; main.go). +func (m *Manager) SetKeptUnitFinder(fn func(app, drive string) string) { m.keptUnitFn = fn } diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index c7fa143..e967b5a 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -48,7 +48,12 @@ type Metadata struct { AppInfo AppInfo `yaml:"app_info" json:"app_info"` OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"` HealthCheck *HealthCheckConfig `yaml:"healthcheck,omitempty" json:"healthcheck,omitempty"` - Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"` + // AfterLoad (`09` §3 decision 36, E1 2026-09-25) is ONE command the box runs once after it loads an + // app's kept data (a database from a backup under files kept on the drive) — for an app whose own + // index of its files must be rebuilt. Measured on nextcloud: a file written after the backup is on + // the drive and invisible until `occ files:scan --all`. Optional; absent = nothing runs. + AfterLoad *AfterLoadCommand `yaml:"after_load,omitempty" json:"after_load,omitempty"` + Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"` // InitialCreds: for apps that auto-generate a first-login credential into a file inside the // container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and // surfaces it on the app page, so the customer never has to dig through logs. Optional. @@ -533,3 +538,10 @@ func (m *Metadata) HasOptionalConfig() bool { func (m *Metadata) HasIntegrations() bool { return len(m.Integrations) > 0 } + +// AfterLoadCommand is `.felhom.yml`'s `after_load:` — run with `docker compose exec -T` in Service. +type AfterLoadCommand struct { + Service string `yaml:"service" json:"service"` + User string `yaml:"user,omitempty" json:"user,omitempty"` + Command []string `yaml:"command" json:"command"` +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index a68bf8a..b17d03f 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -3404,8 +3404,16 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) { } // Generate and write config.yaml (sources + sidebar entries per drive/share) + // `09` §3 decision 36: the read-only „Megőrzött adatok" source — one `:ro` bind per kept item, and + // the source only when there is at least one (a source with no mount is a broken sidebar entry, R-67). + keptLabel := "" + if kb := s.keptFileBrowserBinds(); len(kb) > 0 { + storageMounts = append(storageMounts, kb...) + keptLabel = s.msgLang(s.boxLang(), "kept.fb_source") + } + configPath := stackDir + "/config.yaml" - fbConfig := generateFileBrowserConfig(configPaths, importSource) + fbConfig := infra.RenderFileBrowserConfigKept(configPaths, importSource, keptLabel) // Capture the current on-disk content BEFORE any writes, so we can detect whether this sync // actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml diff --git a/controller/internal/web/i18n_cases_c_test.go b/controller/internal/web/i18n_cases_c_test.go index 29816e8..4249fab 100644 --- a/controller/internal/web/i18n_cases_c_test.go +++ b/controller/internal/web/i18n_cases_c_test.go @@ -36,6 +36,23 @@ func i18nCasesC() []i18nCase { {"storage_empty", "storage", func() map[string]interface{} { return i18nLayoutData("storage", "Tárhely") }}, + // `09` §3 decision 36 — the kept-data list, one dated and one leftover row, and the empty page. + {"kept_data_full", "kept_data", func() map[string]interface{} { + d := i18nLayoutData("kept-data", "Megőrzött adatok") + d["KeptRows"] = []keptRow{ + {DisplayName: "Nextcloud", App: "nextcloud", Path: "/mnt/felhom-drives/hdd_1/kept/nextcloud/2026-09-14_031200", Date: "2026-09-14 05:12", Size: "126.0 MB", + Backup: "unit 2026-09-14 04:44", CanLoad: true, LookURL: "https://files.example.hu/files/x/y", + DeleteText: "delete-text Nextcloud 126.0 MB", TypePrompt: "type-prompt Nextcloud"}, + {DisplayName: "Paperless-ngx", App: "paperless-ngx", Path: "/mnt/felhom-drives/hdd_1/appdata/paperless", Date: "2026-09-13 10:00", Size: "71.0 MB", + Backup: "none", Installed: true, LookURL: "https://files.example.hu/files/x/z", + DeleteText: "x", TypePrompt: "y"}, + } + d["KeptFlash"], d["KeptError"] = "flash-ok", "flash-error" + return d + }}, + {"kept_data_empty", "kept_data", func() map[string]interface{} { + return i18nLayoutData("kept-data", "Megőrzött adatok") + }}, {"storage_network_full", "storage_network", func() map[string]interface{} { d := i18nLayoutData("storage-network", "Hálózati tárhely") base := func(name, health string, orphan bool) m { diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index e247716..65a0dc4 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -61,6 +61,7 @@ var i18nTitleKeys = map[string]string{ "Biztonság és hozzáférés": "page.title.settings_security", "Tárhely": "page.title.storage", "Hálózati tárhely": "page.title.storage_network", + "Megőrzött adatok": "page.title.kept_data", "Új meghajtó inicializálása": "page.title.storage_init", "Meglévő meghajtó csatolása": "page.title.storage_attach", "Hálózati megosztás": "page.title.sharing", diff --git a/controller/internal/web/kept_fb_test.go b/controller/internal/web/kept_fb_test.go new file mode 100644 index 0000000..f7289ff --- /dev/null +++ b/controller/internal/web/kept_fb_test.go @@ -0,0 +1,31 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// `09` §3 decision 36 — the file browser's kept-data view is READ-ONLY: every bind ends in :ro, and +// two items never collide on one name. +// COMPANION RED-PROOF: drop the ":ro" suffix in keptBindLines → fails. +func TestKept_FileBrowserBindsAreReadOnly(t *testing.T) { + items := []stacks.KeptItem{ + {Path: "/mnt/felhom-drives/hdd_1/kept/nextcloud/2026-09-25_130000", Drive: "/mnt/felhom-drives/hdd_1", Kind: stacks.KeptKindDated}, + {Path: "/mnt/felhom-drives/hdd_1/kept/nextcloud/2026-09-26_090000", Drive: "/mnt/felhom-drives/hdd_1", Kind: stacks.KeptKindDated}, + {Path: "/mnt/felhom-drives/hdd_1/appdata/nextcloud", Drive: "/mnt/felhom-drives/hdd_1", Kind: stacks.KeptKindLeftover}, + } + lines := keptBindLines(items) + seen := map[string]bool{} + for i, l := range lines { + if !strings.HasSuffix(l, ":ro") || !strings.HasPrefix(l, " - "+items[i].Path+":/srv/megorzott/") { + t.Fatalf("bind %q is not a read-only bind of %s under /srv/megorzott", l, items[i].Path) + } + target := strings.Split(l, ":")[1] + if seen[target] { + t.Fatalf("two kept items share %s", target) + } + seen[target] = true + } +} diff --git a/controller/internal/web/kept_handlers.go b/controller/internal/web/kept_handlers.go new file mode 100644 index 0000000..660548a --- /dev/null +++ b/controller/internal/web/kept_handlers.go @@ -0,0 +1,209 @@ +package web + +import ( + "net/http" + "net/url" + "path/filepath" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// ── „Megőrzött adatok" / "Kept data" (`09` §3 decision 36) ──────────────────────────────────────── +// +// Every leftover app folder on a connected drive, with the three things a household can do with it: +// Load (install the app with it — only with a database copy), Look (read only, in the file browser) +// and Delete (typed confirmation — the ONLY deletion of kept data in the product; D3 is open, so the box +// never deletes one by itself). + +// keptRow is one row of the page. +type keptRow struct { + DisplayName string + App string + Path string + Drive string + Date string + Size string + Backup string // which copy can bring it back, or none — rendered in the reader's language + CanLoad bool + LookURL string + Installed bool // the app is installed again: Load is not offered + DeleteText string // „A(z) %s megőrzött adatai (%s) véglegesen törlődnek…" in the reader's language + TypePrompt string // what to type to confirm +} + +// keptDrives are the drive roots kept data can sit on: every registered, connected, local drive. +func (s *Server) keptDrives() []string { + var out []string + if s.settings == nil { + return nil + } + for _, sp := range s.settings.GetStoragePaths() { + if sp.IsNetwork() || sp.Disconnected || sp.Path == "" { + continue + } + out = append(out, sp.Path) + } + return out +} + +// KeptViewName is a kept item's folder name inside the file browser's „Megőrzött adatok" source: the +// drive, the app (or folder) and, for a dated folder, its date — unique per item, stable across syncs. +func KeptViewName(it stacks.KeptItem) string { + base := filepath.Base(it.Drive) + "-" + if it.Kind == stacks.KeptKindDated { + return base + filepath.Base(filepath.Dir(it.Path)) + "-" + filepath.Base(it.Path) + } + return base + filepath.Base(it.Path) +} + +// keptBackupFor names the copy a Load would use for it, and whether one exists. +func (s *Server) keptBackupFor(lang string, it stacks.KeptItem) (string, string, bool) { + if s.backupMgr == nil { + return s.msgLang(lang, "kept.backup.none"), "", false + } + if it.Kind == stacks.KeptKindDated { + if it.UnitDir == "" { + return s.msgLang(lang, "kept.backup.none"), "", false + } + if c, ok := s.backupMgr.KeptCopyAt(it.UnitDir, it.Drive, 1); ok { + return s.msgLang(lang, "kept.backup.with", c.Time.In(getTimezone()).Format("2006-01-02 15:04")), c.UnitDir, true + } + return s.msgLang(lang, "kept.backup.none"), "", false + } + if it.App == "" { + return s.msgLang(lang, "kept.backup.none"), "", false + } + c, ok := s.backupMgr.KeptDBCopy(it.App, it.Drive) + if !ok { + return s.msgLang(lang, "kept.backup.none"), "", false + } + key := "kept.backup.own" + if c.Tier == 2 { + key = "kept.backup.second" + } + return s.msgLang(lang, key, c.Time.In(getTimezone()).Format("2006-01-02 15:04")), c.UnitDir, true +} + +func (s *Server) keptPageHandler(w http.ResponseWriter, r *http.Request) { + lang := s.langFor(r) + data := s.baseData("kept-data", "Megőrzött adatok") + data["TitleKey"] = "page.title.kept_data" + var rows []keptRow + if s.stackMgr != nil { + for _, it := range s.stackMgr.ListKept(s.keptDrives()) { + backup, _, can := s.keptBackupFor(lang, it) + row := keptRow{ + DisplayName: it.DisplayName, App: it.App, Path: it.Path, Drive: it.Drive, + Date: it.Date.In(getTimezone()).Format("2006-01-02 15:04"), + Size: appbackup.HumanizeBytes(it.SizeBytes), Backup: backup, CanLoad: can && it.App != "", + LookURL: fileBrowserLink(s.cfg.Customer.Domain, s.msgLang(s.boxLang(), "kept.fb_source"), KeptViewName(it)), + } + row.DeleteText = s.msgLang(lang, "kept.delete.confirm", it.DisplayName, row.Size) + row.TypePrompt = s.msgLang(lang, "kept.delete.type", it.DisplayName) + if st, ok := s.stackMgr.GetStack(it.App); ok && st.Deployed { + row.Installed, row.CanLoad = true, false + } + rows = append(rows, row) + } + } + data["KeptRows"] = rows + go s.SyncFileBrowserMounts() // the read-only view follows the list (no recreate when nothing changed) + data["KeptFlash"] = r.URL.Query().Get("flash") + data["KeptError"] = r.URL.Query().Get("flash_error") + s.executeTemplate(w, r, "kept_data", data) +} + +func (s *Server) keptRedirect(w http.ResponseWriter, r *http.Request, key, val string) { + http.Redirect(w, r, "/kept-data?"+key+"="+url.QueryEscape(val), http.StatusFound) +} + +// keptDeleteHandler — the household's Delete. The typed confirmation must equal the item's name. +func (s *Server) keptDeleteHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + lang := s.langFor(r) + path, confirm := r.FormValue("path"), strings.TrimSpace(r.FormValue("confirm")) + it, ok := s.stackMgr.FindKept(s.keptDrives(), path) + if !ok { + s.logger.Printf("[WARN] [web] kept delete REFUSED: %q is not a listed kept item (from %s)", path, r.RemoteAddr) + s.keptRedirect(w, r, "flash_error", s.msgLang(lang, "err.kept.not_listed")) + return + } + if confirm != it.DisplayName { + s.logger.Printf("[WARN] [web] kept delete REFUSED for %s: the typed confirmation did not match", it.Path) + s.keptRedirect(w, r, "flash_error", s.msgLang(lang, "kept.delete.mismatch", it.DisplayName)) + return + } + if _, err := s.stackMgr.DeleteKept(s.keptDrives(), path); err != nil { + s.keptRedirect(w, r, "flash_error", s.errText(r, err)) + return + } + go s.SyncFileBrowserMounts() // the view follows the list + s.keptRedirect(w, r, "flash", s.msgLang(lang, "kept.deleted", it.DisplayName)) +} + +// keptLoadHandler — Load: install the app with this data (the same act as „use my kept data"). +func (s *Server) keptLoadHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + lang := s.langFor(r) + it, ok := s.stackMgr.FindKept(s.keptDrives(), r.FormValue("path")) + if !ok { + s.keptRedirect(w, r, "flash_error", s.msgLang(lang, "err.kept.not_listed")) + return + } + if st, ok := s.stackMgr.GetStack(it.App); it.App == "" || !ok || st.Deployed { + s.keptRedirect(w, r, "flash_error", s.msgLang(lang, "kept.load.installed", it.DisplayName)) + return + } + if msg, blocked := s.restoreOpBlocked(); blocked { + s.keptRedirect(w, r, "flash_error", msg) + return + } + _, unit, can := s.keptBackupFor(lang, it) + if !can { + s.keptRedirect(w, r, "flash_error", s.msgLang(lang, "kept.choice.use_off")) + return + } + if it.Kind == stacks.KeptKindDated { + if _, err := s.stackMgr.RestoreKeptFiles(it); err != nil { + s.keptRedirect(w, r, "flash_error", s.errText(r, err)) + return + } + } + s.logger.Printf("[INFO] [web] kept LOAD %s: %s from %s (from %s)", it.App, it.Path, unit, r.RemoteAddr) + app, disp := it.App, it.DisplayName + s.backupMgr.LoadKeptApp(app, unit, + func(error) string { return s.msgLang(lang, "kept.load.done", disp) }, + func(err error) string { return s.msgLang(lang, "kept.load.failed", disp, err) }, + func(ok bool) { + if ok { + if ran, err := s.stackMgr.RunAfterLoad(app, 10*time.Minute); ran && err != nil { + s.logger.Printf("[WARN] [web] kept load %s: after_load failed: %v", app, err) + } + s.stackMgr.FinishKeptLoad(it, s.backupMgr.PrimaryUnitHome(app, it.Drive)) + } + s.SyncFileBrowserMounts() + }) + http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound) +} + +// keptFileBrowserBinds are the read-only binds of the „Megőrzött adatok" source: one per listed item, +// `:ro`, under /srv/ — never a live app's folder (ListKept never lists one). +func (s *Server) keptFileBrowserBinds() []string { + if s.stackMgr == nil { + return nil + } + return keptBindLines(s.stackMgr.ListKept(s.keptDrives())) +} + +// keptBindLines renders the compose bind lines — each READ-ONLY. Pinned by TestKept_FileBrowserBindsAreReadOnly. +func keptBindLines(items []stacks.KeptItem) []string { + var out []string + for _, it := range items { + out = append(out, " - "+it.Path+":/srv/"+infra.FileBrowserKeptMount+"/"+KeptViewName(it)+":ro") + } + return out +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 5b617ec..d0147da 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -632,6 +632,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.settingsHandler(w, r) case path == "/storage" && r.Method == http.MethodGet: s.storagePageHandler(w, r) + case path == "/kept-data" && r.Method == http.MethodGet: + s.keptPageHandler(w, r) + case path == "/kept-data/delete" && r.Method == http.MethodPost: + s.keptDeleteHandler(w, r) + case path == "/kept-data/load" && r.Method == http.MethodPost: + s.keptLoadHandler(w, r) case path == "/storage/network" && r.Method == http.MethodGet: s.storageNetworkPageHandler(w, r) // „Megosztás" — LAN network sharing (R-7 slice 1) diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index 583303c..d415510 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -449,6 +449,7 @@ {{end}} {{end}} +
{{/* 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: @@ -680,6 +681,32 @@ var postDeployInfo = { deployFields: {{json .Meta.DeployFields}} }; +// showKeptChoice renders the two choices of `09` §3 decision 36. Every sentence arrives rendered in the +// reader's language from the server (textContent only — nothing is parsed as HTML). +function showKeptChoice(d, form) { + var box = document.getElementById('kept-choice'); + box.textContent = ''; + function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; } + box.appendChild(el('h3', 'kept-choice-title', d.title)); + box.appendChild(el('p', 'kept-choice-body', d.body)); + function option(label, desc, choice, enabled, extra) { + var o = el('div', 'kept-choice-option'); + var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label); + b.type = 'button'; + b.disabled = !enabled; + b.setAttribute('data-kept-choice', choice); + b.addEventListener('click', function () { window.keptChoice = choice; box.hidden = true; form.requestSubmit(); }); + o.appendChild(b); + o.appendChild(el('p', 'kept-choice-desc', desc)); + if (extra) o.appendChild(el('p', 'kept-choice-note', extra)); + return o; + } + box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, '')); + box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up)); + box.hidden = false; + box.scrollIntoView({behavior: 'smooth'}); +} + function buildPostDeployCard(stackName) { var subdomain = ''; var sdField = document.getElementById('field-SUBDOMAIN'); @@ -992,9 +1019,16 @@ document.getElementById('deploy-form').addEventListener('submit', async function var resp = await fetch('/api/stacks/' + stackName + '/deploy', { method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), - body: JSON.stringify({values: values}) + body: JSON.stringify({values: values, kept_data: window.keptChoice || ''}) }); var data = await resp.json(); + // `09` §3 decision 36: the drive already holds this app's old data — the household chooses. + if (!data.ok && data.data && data.data.code === 'kept_data_choice') { + showKeptChoice(data.data, e.target); + btn.textContent = '{{T "deploy.telepites_inditasa"}}'; + btn.disabled = false; + return; + } if (!data.ok) { showAlert('{{T "common.hiba_kettospont"}} ' + data.error); btn.textContent = '{{T "deploy.telepites_inditasa"}}'; @@ -1015,7 +1049,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function // Phase 2: Poll stack status var startTime = Date.now(); - var pollTimeout = 120000; + var pollTimeout = window.keptChoice === 'use' ? 900000 : 120000; // a load restores a backup first var pollTimer = setInterval(async function() { var elapsed = Math.round((Date.now() - startTime) / 1000); elapsedEl.textContent = elapsed + ' {{T "deploy.masodperce"}}'; diff --git a/controller/internal/web/templates/kept_data.html b/controller/internal/web/templates/kept_data.html new file mode 100644 index 0000000..a9dd93c --- /dev/null +++ b/controller/internal/web/templates/kept_data.html @@ -0,0 +1,58 @@ +{{define "kept_data"}} +{{template "layout_start" .}} + + + +{{if .KeptError}}
{{.KeptError}}
{{end}} +{{if .KeptFlash}}
{{.KeptFlash}}
{{end}} + +
+

{{T "kept.page.intro"}}

+

{{T "kept.not_backed_up"}}

+ {{if .KeptRows}} +
+ {{range .KeptRows}} +
+
+ {{.DisplayName}} + {{.Date}} · {{.Size}} +
+
{{T "kept.list.backup"}} {{.Backup}}
+
+ {{if .CanLoad}} + + {{$.CSRFField}} + + + + {{else if .Installed}} + {{T "kept.list.installed"}} + {{end}} + {{T "kept.action.look"}} +
+
+ {{T "kept.action.delete"}} +
+ {{$.CSRFField}} + +

{{.DeleteText}}

+ + + +
+
+
+ {{end}} +
+ {{else}} +

{{T "kept.list.empty"}}

+ {{end}} +
+ +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/storage.html b/controller/internal/web/templates/storage.html index f31789a..fe3c9f9 100644 --- a/controller/internal/web/templates/storage.html +++ b/controller/internal/web/templates/storage.html @@ -12,6 +12,7 @@ {{if .StorageError}}
{{.StorageError}}
{{end}} {{if .StorageSuccess}}
{{.StorageSuccess}}
{{end}} + {{if .StoragePaths}}
diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 8c9c2f4..4fbbe8a 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -3682,3 +3682,25 @@ html::-webkit-scrollbar-track { background: var(--bg-0); } the shared .lang-globe-menu rule, so this block adds no positioning of its own. It floated in the top-left of the viewport before, outside the card, which read as a stray browser control. */ .shell-lang { display: flex; justify-content: center; margin-top: 1.5rem; } + +/* ── Kept data (`09` §3 decision 36): the install-time choice and the „Megőrzött adatok" list ── */ +.kept-choice { margin-bottom: 1rem; } +.kept-choice-title { margin: 0 0 .5rem; } +.kept-choice-body { color: var(--text-1); margin: 0 0 1rem; } +.kept-choice-option { border-top: 1px solid var(--line-soft); padding: .75rem 0; } +.kept-choice-desc, .kept-choice-note { color: var(--text-2); margin: .4rem 0 0; font-size: .9rem; } +.kept-choice-note { color: var(--text-3); } +.kept-header { display: flex; align-items: center; gap: .5rem; } +.kept-list { display: flex; flex-direction: column; gap: .75rem; margin-top: .75rem; } +.kept-item { border: 1px solid var(--line); border-radius: var(--radius); padding: .75rem; background: var(--bg-1); } +.kept-item-head { display: flex; justify-content: space-between; gap: .75rem; flex-wrap: wrap; } +.kept-item-name { font-weight: 600; color: var(--text-1); } +.kept-item-meta { color: var(--text-2); } +.kept-item-backup { color: var(--text-2); margin: .35rem 0 .5rem; font-size: .9rem; } +.kept-item-actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; } +.kept-inline-form { display: inline; margin: 0; } +.kept-delete { margin-top: .5rem; } +.kept-delete summary { list-style: none; display: inline-block; cursor: pointer; } +.kept-delete-form { display: flex; flex-direction: column; gap: .4rem; margin-top: .5rem; max-width: 32rem; } +.kept-delete-text { color: var(--warn); margin: 0; } +.storage-kept-link { margin-top: .75rem; } diff --git a/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html b/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html index ec539e5..ab43d72 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html @@ -664,6 +664,7 @@ +
@@ -919,6 +920,32 @@ var postDeployInfo = { deployFields: null }; + + +function showKeptChoice(d, form) { + var box = document.getElementById('kept-choice'); + box.textContent = ''; + function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; } + box.appendChild(el('h3', 'kept-choice-title', d.title)); + box.appendChild(el('p', 'kept-choice-body', d.body)); + function option(label, desc, choice, enabled, extra) { + var o = el('div', 'kept-choice-option'); + var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label); + b.type = 'button'; + b.disabled = !enabled; + b.setAttribute('data-kept-choice', choice); + b.addEventListener('click', function () { window.keptChoice = choice; box.hidden = true; form.requestSubmit(); }); + o.appendChild(b); + o.appendChild(el('p', 'kept-choice-desc', desc)); + if (extra) o.appendChild(el('p', 'kept-choice-note', extra)); + return o; + } + box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, '')); + box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up)); + box.hidden = false; + box.scrollIntoView({behavior: 'smooth'}); +} + function buildPostDeployCard(stackName) { var subdomain = ''; var sdField = document.getElementById('field-SUBDOMAIN'); @@ -1231,9 +1258,16 @@ document.getElementById('deploy-form').addEventListener('submit', async function var resp = await fetch('/api/stacks/' + stackName + '/deploy', { method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), - body: JSON.stringify({values: values}) + body: JSON.stringify({values: values, kept_data: window.keptChoice || ''}) }); var data = await resp.json(); + + if (!data.ok && data.data && data.data.code === 'kept_data_choice') { + showKeptChoice(data.data, e.target); + btn.textContent = 'Telepítés indítása'; + btn.disabled = false; + return; + } if (!data.ok) { showAlert('Hiba: ' + data.error); btn.textContent = 'Telepítés indítása'; @@ -1254,7 +1288,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function var startTime = Date.now(); - var pollTimeout = 120000; + var pollTimeout = window.keptChoice === 'use' ? 900000 : 120000; var pollTimer = setInterval(async function() { var elapsed = Math.round((Date.now() - startTime) / 1000); elapsedEl.textContent = elapsed + ' másodperce...'; diff --git a/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html b/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html index d83718e..61e1c52 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html @@ -613,6 +613,7 @@ + @@ -846,6 +847,32 @@ var postDeployInfo = { deployFields: null }; + + +function showKeptChoice(d, form) { + var box = document.getElementById('kept-choice'); + box.textContent = ''; + function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; } + box.appendChild(el('h3', 'kept-choice-title', d.title)); + box.appendChild(el('p', 'kept-choice-body', d.body)); + function option(label, desc, choice, enabled, extra) { + var o = el('div', 'kept-choice-option'); + var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label); + b.type = 'button'; + b.disabled = !enabled; + b.setAttribute('data-kept-choice', choice); + b.addEventListener('click', function () { window.keptChoice = choice; box.hidden = true; form.requestSubmit(); }); + o.appendChild(b); + o.appendChild(el('p', 'kept-choice-desc', desc)); + if (extra) o.appendChild(el('p', 'kept-choice-note', extra)); + return o; + } + box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, '')); + box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up)); + box.hidden = false; + box.scrollIntoView({behavior: 'smooth'}); +} + function buildPostDeployCard(stackName) { var subdomain = ''; var sdField = document.getElementById('field-SUBDOMAIN'); @@ -1158,9 +1185,16 @@ document.getElementById('deploy-form').addEventListener('submit', async function var resp = await fetch('/api/stacks/' + stackName + '/deploy', { method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), - body: JSON.stringify({values: values}) + body: JSON.stringify({values: values, kept_data: window.keptChoice || ''}) }); var data = await resp.json(); + + if (!data.ok && data.data && data.data.code === 'kept_data_choice') { + showKeptChoice(data.data, e.target); + btn.textContent = 'Telepítés indítása'; + btn.disabled = false; + return; + } if (!data.ok) { showAlert('Hiba: ' + data.error); btn.textContent = 'Telepítés indítása'; @@ -1181,7 +1215,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function var startTime = Date.now(); - var pollTimeout = 120000; + var pollTimeout = window.keptChoice === 'use' ? 900000 : 120000; var pollTimer = setInterval(async function() { var elapsed = Math.round((Date.now() - startTime) / 1000); elapsedEl.textContent = elapsed + ' másodperce...'; diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new.html b/controller/internal/web/testdata/i18n_parity/deploy_new.html index 9de693e..5bd84bf 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new.html @@ -610,6 +610,7 @@ + @@ -895,6 +896,32 @@ var postDeployInfo = { deployFields: null }; + + +function showKeptChoice(d, form) { + var box = document.getElementById('kept-choice'); + box.textContent = ''; + function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; } + box.appendChild(el('h3', 'kept-choice-title', d.title)); + box.appendChild(el('p', 'kept-choice-body', d.body)); + function option(label, desc, choice, enabled, extra) { + var o = el('div', 'kept-choice-option'); + var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label); + b.type = 'button'; + b.disabled = !enabled; + b.setAttribute('data-kept-choice', choice); + b.addEventListener('click', function () { window.keptChoice = choice; box.hidden = true; form.requestSubmit(); }); + o.appendChild(b); + o.appendChild(el('p', 'kept-choice-desc', desc)); + if (extra) o.appendChild(el('p', 'kept-choice-note', extra)); + return o; + } + box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, '')); + box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up)); + box.hidden = false; + box.scrollIntoView({behavior: 'smooth'}); +} + function buildPostDeployCard(stackName) { var subdomain = ''; var sdField = document.getElementById('field-SUBDOMAIN'); @@ -1207,9 +1234,16 @@ document.getElementById('deploy-form').addEventListener('submit', async function var resp = await fetch('/api/stacks/' + stackName + '/deploy', { method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), - body: JSON.stringify({values: values}) + body: JSON.stringify({values: values, kept_data: window.keptChoice || ''}) }); var data = await resp.json(); + + if (!data.ok && data.data && data.data.code === 'kept_data_choice') { + showKeptChoice(data.data, e.target); + btn.textContent = 'Telepítés indítása'; + btn.disabled = false; + return; + } if (!data.ok) { showAlert('Hiba: ' + data.error); btn.textContent = 'Telepítés indítása'; @@ -1230,7 +1264,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function var startTime = Date.now(); - var pollTimeout = 120000; + var pollTimeout = window.keptChoice === 'use' ? 900000 : 120000; var pollTimer = setInterval(async function() { var elapsed = Math.round((Date.now() - startTime) / 1000); elapsedEl.textContent = elapsed + ' másodperce...'; diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html b/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html index 5c94a01..d73d4ab 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html @@ -590,6 +590,7 @@ + @@ -844,6 +845,32 @@ var postDeployInfo = { deployFields: null }; + + +function showKeptChoice(d, form) { + var box = document.getElementById('kept-choice'); + box.textContent = ''; + function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; } + box.appendChild(el('h3', 'kept-choice-title', d.title)); + box.appendChild(el('p', 'kept-choice-body', d.body)); + function option(label, desc, choice, enabled, extra) { + var o = el('div', 'kept-choice-option'); + var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label); + b.type = 'button'; + b.disabled = !enabled; + b.setAttribute('data-kept-choice', choice); + b.addEventListener('click', function () { window.keptChoice = choice; box.hidden = true; form.requestSubmit(); }); + o.appendChild(b); + o.appendChild(el('p', 'kept-choice-desc', desc)); + if (extra) o.appendChild(el('p', 'kept-choice-note', extra)); + return o; + } + box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, '')); + box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up)); + box.hidden = false; + box.scrollIntoView({behavior: 'smooth'}); +} + function buildPostDeployCard(stackName) { var subdomain = ''; var sdField = document.getElementById('field-SUBDOMAIN'); @@ -1156,9 +1183,16 @@ document.getElementById('deploy-form').addEventListener('submit', async function var resp = await fetch('/api/stacks/' + stackName + '/deploy', { method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), - body: JSON.stringify({values: values}) + body: JSON.stringify({values: values, kept_data: window.keptChoice || ''}) }); var data = await resp.json(); + + if (!data.ok && data.data && data.data.code === 'kept_data_choice') { + showKeptChoice(data.data, e.target); + btn.textContent = 'Telepítés indítása'; + btn.disabled = false; + return; + } if (!data.ok) { showAlert('Hiba: ' + data.error); btn.textContent = 'Telepítés indítása'; @@ -1179,7 +1213,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function var startTime = Date.now(); - var pollTimeout = 120000; + var pollTimeout = window.keptChoice === 'use' ? 900000 : 120000; var pollTimer = setInterval(async function() { var elapsed = Math.round((Date.now() - startTime) / 1000); elapsedEl.textContent = elapsed + ' másodperce...'; diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html b/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html index 8bc9ed0..94ee87f 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html @@ -595,6 +595,7 @@ + @@ -874,6 +875,32 @@ var postDeployInfo = { deployFields: null }; + + +function showKeptChoice(d, form) { + var box = document.getElementById('kept-choice'); + box.textContent = ''; + function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; } + box.appendChild(el('h3', 'kept-choice-title', d.title)); + box.appendChild(el('p', 'kept-choice-body', d.body)); + function option(label, desc, choice, enabled, extra) { + var o = el('div', 'kept-choice-option'); + var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label); + b.type = 'button'; + b.disabled = !enabled; + b.setAttribute('data-kept-choice', choice); + b.addEventListener('click', function () { window.keptChoice = choice; box.hidden = true; form.requestSubmit(); }); + o.appendChild(b); + o.appendChild(el('p', 'kept-choice-desc', desc)); + if (extra) o.appendChild(el('p', 'kept-choice-note', extra)); + return o; + } + box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, '')); + box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up)); + box.hidden = false; + box.scrollIntoView({behavior: 'smooth'}); +} + function buildPostDeployCard(stackName) { var subdomain = ''; var sdField = document.getElementById('field-SUBDOMAIN'); @@ -1186,9 +1213,16 @@ document.getElementById('deploy-form').addEventListener('submit', async function var resp = await fetch('/api/stacks/' + stackName + '/deploy', { method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), - body: JSON.stringify({values: values}) + body: JSON.stringify({values: values, kept_data: window.keptChoice || ''}) }); var data = await resp.json(); + + if (!data.ok && data.data && data.data.code === 'kept_data_choice') { + showKeptChoice(data.data, e.target); + btn.textContent = 'Telepítés indítása'; + btn.disabled = false; + return; + } if (!data.ok) { showAlert('Hiba: ' + data.error); btn.textContent = 'Telepítés indítása'; @@ -1209,7 +1243,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function var startTime = Date.now(); - var pollTimeout = 120000; + var pollTimeout = window.keptChoice === 'use' ? 900000 : 120000; var pollTimer = setInterval(async function() { var elapsed = Math.round((Date.now() - startTime) / 1000); elapsedEl.textContent = elapsed + ' másodperce...'; diff --git a/controller/internal/web/testdata/i18n_parity/kept_data_empty.html b/controller/internal/web/testdata/i18n_parity/kept_data_empty.html new file mode 100644 index 0000000..4c3228d --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/kept_data_empty.html @@ -0,0 +1,521 @@ + + + + + + + + Megőrzött adatok — Felhom.eu + + + + + + + + +
+ + +
+ +
+
+ + + + + + + + + + + + + +
+

Az eltávolított alkalmazások lemezen maradt adatai. Megnézheted őket, betöltheted egy új telepítésbe, vagy törölheted. Magától a szerver soha nem töröl közülük semmit.

+

Erről nem készül mentés.

+ +

Nincs megőrzött adat.

+ +
+ + +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/kept_data_full.html b/controller/internal/web/testdata/i18n_parity/kept_data_full.html new file mode 100644 index 0000000..3c50f01 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/kept_data_full.html @@ -0,0 +1,577 @@ + + + + + + + + Megőrzött adatok — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + +
flash-error
+
flash-ok
+ +
+

Az eltávolított alkalmazások lemezen maradt adatai. Megnézheted őket, betöltheted egy új telepítésbe, vagy törölheted. Magától a szerver soha nem töröl közülük semmit.

+

Erről nem készül mentés.

+ +
+ +
+
+ Nextcloud + 2026-09-14 05:12 · 126.0 MB +
+
Visszatölthető innen: unit 2026-09-14 04:44
+
+ +
+ + + +
+ + Megnézem +
+
+ Törlés +
+ + +

delete-text Nextcloud 126.0 MB

+ + + +
+
+
+ +
+
+ Paperless-ngx + 2026-09-13 10:00 · 71.0 MB +
+
Visszatölthető innen: none
+
+ + Az alkalmazás újra telepítve van. + + Megnézem +
+
+ Törlés +
+ + +

x

+ + + +
+
+
+ +
+ +
+ + +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/storage_empty.html b/controller/internal/web/testdata/i18n_parity/storage_empty.html index 781d012..7ecc0ec 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_empty.html +++ b/controller/internal/web/testdata/i18n_parity/storage_empty.html @@ -178,6 +178,7 @@ +
diff --git a/controller/internal/web/testdata/i18n_parity/storage_full.html b/controller/internal/web/testdata/i18n_parity/storage_full.html index 71ef6dd..ccdd8fe 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_full.html +++ b/controller/internal/web/testdata/i18n_parity/storage_full.html @@ -178,6 +178,7 @@
Az útvonal nem írható most.
A tárhely hozzáadva rendben.
+
diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index b14a7bb..846c2d9 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -84,7 +84,33 @@ "backup.tier.no_space_unknown": "BORN AS A KEY, v0.272.0 (R-685 page half) -- a NEW sentence, never a Go literal. Pinned by internal/web/r685_no_space_test.go.", "err.stacks.update_convert_space": "BORN AS A KEY, v0.273.0 (`09` §6.4 part 10, the PostgreSQL conversion) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/pgconvert_test.go.", "err.stacks.update_engine_no_test": "BORN AS A KEY, v0.273.0 (`09` §6.4 part 10, the PostgreSQL conversion) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/pgconvert_test.go.", - "update.phase.converting": "BORN AS A KEY, v0.273.0 (`09` §6.4 part 10, the PostgreSQL conversion) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/pgconvert_test.go." + "update.phase.converting": "BORN AS A KEY, v0.273.0 (`09` §6.4 part 10, the PostgreSQL conversion) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/pgconvert_test.go.", + "api.kept.busy": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "err.kept.not_listed": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "err.kept.occupied": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.backup.none": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.backup.own": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.backup.second": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.backup.with": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.choice.body": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.choice.fresh.desc": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.choice.fresh.label": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.choice.title": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.choice.use.desc": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.choice.use.label": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.choice.use_off": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.delete.confirm": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.delete.mismatch": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.delete.type": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.deleted": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.diskwarn": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.fb_source": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.load.done": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.load.failed": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.load.installed": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.load.started": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "kept.not_backed_up": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", + "page.title.kept_data": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go." }, "flash.share.already_on": "A megosztás már be van kapcsolva.", "flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",