Files
felhom-controller/controller/internal/backup/offbox_verify_copies.go
T
admin b5d78d1e0f v0.147.0 — feedback slice 1: pressing a button says something
The systemic complaint, twice in one evening: you press a button and nothing
happens. No progress, no ETA, no named result. Three worst offenders, fixed on
the two patterns already here (deploy 3-step panel, storage-init status poll).
No new framework — that is a ROADMAP item; three targeted cards ship tonight.

4a — a verification restore names its result. The flash said the app had been
restored "to a verification folder on the drive"; which folder, on which drive,
was invisible, so the customer could not go and look at what they had just asked
for. Full path now. The restore page gained a listing of existing verification
copies (app, size, date, path) — nothing anywhere showed these, so they piled up
and the only way to find them was SSH — each with a double-confirmed delete.

That delete is the only one this release adds, so it names a STACK, never a
path: the Manager resolves the name inside a backups/offsite-restore root it
computed itself and refuses anything landing outside. Red-proofed — neutralise
the name guard and stack:"" resolves to the offsite-restore ROOT and takes every
copy with it. Refusals are asserted as non-effects.

4b — Megosztás enable shows what it is waiting for. Enabling ran ReconcileSamba
synchronously inside the POST handler; on a golden without felhom-samba baked
that is compose pulling ~100MB, i.e. minutes of an apparently-hung form post
followed by "Beállítás mentve." whether or not anything came up. Detached +
polled now, distinguishing "képfájl letöltése" from "indítás" — decided BEFORE
the work starts, since afterwards the image is always present. Success is
probed, not inferred (compose up -d exits 0 on a crash-loop). The password form
starts the same job: with UserSet false reconcile deploys nothing, so on a fresh
box that is where the pull actually happens.

4c — "Távoli mentés most" streams real progress. restic was already reporting
bytes and percent; the runner seam used CombinedOutput() and discarded them. The
manual run now passes --json and scans stdout line-by-line: total bytes, percent,
current app. Manual only — the nightly stays silent, pinned by a test that fails
if it ever passes --json. The poll now arms unconditionally, closing a race the
manual trigger always ran: the redirect rendered before the goroutine wrote
LastStatus=running, so the poll never armed and the page sat static during the
very run just started. Red-proofed twice.

Also closes the golden/controller infra-image drift at the source: infra.Images()
derives from the existing pins and --print-infra-images exposes it, so the golden
bake can stop carrying its own copy. That copy had already drifted — felhom-samba
was never added, so the golden baked 3 of 4, which is why enabling Megosztás
pulled at runtime in the first place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 09:30:30 +02:00

146 lines
5.7 KiB
Go

package backup
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Verification copies — the listing/delete surface for `<nsRoot>/backups/offsite-restore/<app>`.
//
// WHY THIS EXISTS (v0.147.0, feedback slice 4a): an offsite verification restore wrote its result to
// a path the customer was never told, and nothing anywhere listed what had accumulated. Pressing
// „Ellenőrző visszaállítás" produced a flash saying it had been restored "to a verification folder
// on the drive" — which folder, on which drive, and how much space it was now using were all
// invisible. So copies piled up and the only way to find them was SSH.
//
// The path segments were already open-coded in three places; offsiteRestoreRootFor() is now the one
// place `backups/offsite-restore` is spelled, and offboxRestoreScratchDir() builds on it.
// OffsiteRestoreCopy is one verification copy on disk.
type OffsiteRestoreCopy struct {
Stack string `json:"stack"` // app slug, or SharesPseudoStack for the shares copy
Path string `json:"path"` // absolute path — the thing the customer could not see
Size int64 `json:"size"` // bytes
SizeHuman string `json:"size_human"` // pre-humanized for the template
Created time.Time `json:"created"` // dir mtime; restic writes the tree once, so this is the restore time
}
// offsiteRestoreRootFor returns `<nsRoot>/backups/offsite-restore` for a drive path. THE single place
// these segments are written.
func (m *Manager) offsiteRestoreRootFor(drivePath string) string {
return filepath.Join(m.namespaceRoot(drivePath), "backups", "offsite-restore")
}
// offsiteRestoreDriveRoots returns every drive path a verification copy could live under, in the same
// preference order offboxRestoreScratchDir uses to CHOOSE one — so listing can never miss a copy the
// restore path was capable of creating. Deduplicated, order preserved.
func (m *Manager) offsiteRestoreDriveRoots() []string {
seen := map[string]bool{}
var roots []string
add := func(p string) {
p = strings.TrimSpace(p)
if p == "" || seen[p] {
return
}
seen[p] = true
roots = append(roots, p)
}
// App HDDs first (offboxRestoreScratchDir's rule 1), then every schedulable path (rules 2 and 3).
if m.stackProvider != nil {
for _, s := range m.stackProvider.ListDeployedStacks() {
add(m.stackProvider.GetStackHDDPath(s.Name))
}
}
if m.settings != nil {
for _, sp := range m.settings.GetSchedulableStoragePaths() {
add(sp.Path)
}
}
return roots
}
// ListOffsiteRestoreCopies enumerates every verification copy across every candidate drive, newest
// first. Missing directories are not an error — "none yet" is the normal state.
func (m *Manager) ListOffsiteRestoreCopies() []OffsiteRestoreCopy {
sizer := m.offboxSize()
var out []OffsiteRestoreCopy
seen := map[string]bool{}
for _, drive := range m.offsiteRestoreDriveRoots() {
root := m.offsiteRestoreRootFor(drive)
entries, err := os.ReadDir(root)
if err != nil {
continue // no copies on this drive (or the drive is not mounted) — not an error
}
for _, e := range entries {
if !e.IsDir() {
continue
}
p := filepath.Join(root, e.Name())
if seen[p] {
continue // two stacks can resolve to the same drive; list each path once
}
seen[p] = true
c := OffsiteRestoreCopy{Stack: e.Name(), Path: p}
if fi, err := e.Info(); err == nil {
c.Created = fi.ModTime()
}
c.Size = sizer(p)
c.SizeHuman = humanizeBytes(c.Size)
out = append(out, c)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) })
return out
}
// DeleteOffsiteRestoreCopy removes ONE verification copy.
//
// This is the only delete path v0.147.0 adds, so it is guarded twice over. The stack name must pass
// isSafeStackName (no separators, no traversal), and the resolved path must sit STRICTLY INSIDE a
// `backups/offsite-restore` root that this Manager itself computed — a path that merely looks right
// is refused. Both checks are on the RESOLVED path, not the input, so a symlinked scratch cannot
// walk the delete out of the sandbox.
func (m *Manager) DeleteOffsiteRestoreCopy(stack string) error {
if !isSafeStackName(stack) {
return fmt.Errorf("érvénytelen alkalmazásnév")
}
for _, drive := range m.offsiteRestoreDriveRoots() {
root := m.offsiteRestoreRootFor(drive)
target := filepath.Join(root, stack)
fi, err := os.Stat(target)
if err != nil || !fi.IsDir() {
continue
}
// Prefix safety: only ever remove strictly inside `backups/offsite-restore/`. Same shape as
// the F5 stale-primary prune (backup.go) — refuse loudly rather than best-effort skip, since
// reaching here with an out-of-sandbox path means a helper above is wrong.
cleanTarget := filepath.Clean(target)
cleanRoot := filepath.Clean(root) + string(filepath.Separator)
if !strings.HasPrefix(cleanTarget+string(filepath.Separator), cleanRoot) {
m.logger.Printf("[WARN] [offbox] refusing to delete verification copy outside %s: %s", root, cleanTarget)
return fmt.Errorf("a törlés útvonala kívül esik az ellenőrző mappán")
}
if err := os.RemoveAll(cleanTarget); err != nil {
return fmt.Errorf("a másolat törlése nem sikerült: %w", err)
}
m.logger.Printf("[INFO] [offbox] deleted verification copy: %s", cleanTarget)
return nil
}
return fmt.Errorf("nincs ilyen ellenőrző másolat")
}
// OffsiteRestoreScratchPath exposes WHERE a verification restore for stack would land, so the UI can
// name the full path in the completion message instead of saying "a verification folder somewhere".
func (m *Manager) OffsiteRestoreScratchPath(stack string) string {
scratch, _, err := m.offboxRestoreScratchDir(stack)
if err != nil {
return ""
}
return scratch
}