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
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user