b5d78d1e0f
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
146 lines
5.7 KiB
Go
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
|
|
}
|