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:
2026-07-19 09:30:30 +02:00
parent 7f0b41c3e7
commit b5d78d1e0f
22 changed files with 1527 additions and 21 deletions
+5
View File
@@ -35,6 +35,11 @@ type Manager struct {
// offbox (Part B): the restic-SFTP exec seam (nil → real restic) + the failure→operator-alert hook.
offboxRunner offboxRunner
offboxNotify func(dur time.Duration, snapshots int, err error)
// offboxStreamRunner + offboxProgress: the MANUAL run's live progress (v0.147.0, 4c). The stream
// seam scans restic's `--json` stdout line-by-line; the state is what the page polls. Both are
// inert on the nightly path — the sink is installed only for the duration of a manual run.
offboxStreamRunner offboxStreamRunner
offboxProgress offboxProgressState
// offboxOrphanEvent (v0.142.0), if set, pushes a hub event on offsite-repo continuity transitions
// ("offbox_repo_orphaned" / "offbox_repo_reset"); renamedTo names the move-aside path (reset only).
// Wired in main.go to the notifier. Nil-safe.
+19 -1
View File
@@ -572,6 +572,22 @@ func (m *Manager) ensureOffboxRepo(ctx context.Context, base, env []string) erro
// tars) to the SFTP repo, then prunes per the retention policy. Single-flight + migration-guarded. A
// failure (incl. a fail-fast dead-NAS error) records status + alerts the operator. Returns the first error.
func (m *Manager) RunOffboxBackup(ctx context.Context) error {
return m.runOffboxBackup(ctx, false)
}
// RunOffboxBackupWithProgress is the MANUAL („Távoli mentés most") entry point: identical work, but
// with the live progress sink installed so the page can show total bytes, percent and current app
// (v0.147.0, 4c). The nightly scheduled run keeps calling RunOffboxBackup and stays silent — nobody
// is watching a progress bar at 03:00, and a sink left installed would publish stale percentages
// into a page that never asked for them.
func (m *Manager) RunOffboxBackupWithProgress(ctx context.Context) error {
return m.runOffboxBackup(ctx, true)
}
func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error {
if withProgress {
defer m.beginManualProgress()()
}
if !m.OffboxConfigured() {
return fmt.Errorf("off-box backup not configured")
}
@@ -890,7 +906,9 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
}
args := append([]string{"backup", "--tag", "felhom-offbox", "--tag", stack, src}, extra...)
bctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout)
out, berr := m.resticStep(bctx, env, base, "backup:"+stack, args...)
// Streaming twin (4c): identical to resticStep unless a MANUAL run installed a progress sink,
// in which case it adds --json and feeds the parsed status lines to the page's poll.
out, berr := m.resticBackupStep(bctx, env, base, "backup:"+stack, stack, args...)
cancel()
if berr != nil {
m.logger.Printf("[ERROR] [offbox] backup %s failed: %v: %s", stack, berr, truncate(out))
@@ -0,0 +1,257 @@
package backup
import (
"bufio"
"context"
"encoding/json"
"io"
"os"
"os/exec"
"sync"
"time"
)
// Offsite backup progress (v0.147.0, feedback slice 4c).
//
// THE PROBLEM: „Távoli mentés most" started a background restic run and redirected with „A távoli
// mentés elindult". After that the page polled a status field whose only values were running / ok /
// error. For a first offsite push of tens of gigabytes over SFTP that is 20+ minutes of a spinner
// with no total, no percentage and no indication of WHICH app is being pushed — indistinguishable
// from a hang.
//
// restic already reports all of it: `backup --json` writes newline-delimited status objects to
// stdout. We only had to stop throwing them away — the existing runner seam uses CombinedOutput(),
// which buffers everything until exit.
//
// SCOPE: the MANUAL trigger only. The nightly scheduled run stays silent (nobody is watching a
// progress bar at 03:00, and a sink left installed would keep publishing stale percentages into a
// page that never asked). The sink is installed for the duration of a manual run and cleared after.
// OffboxProgress is a snapshot of an in-flight manual offsite backup.
type OffboxProgress struct {
Active bool `json:"active"`
CurrentApp string `json:"current_app"`
Percent float64 `json:"percent"` // 0..100
BytesDone int64 `json:"bytes_done"`
TotalBytes int64 `json:"total_bytes"`
DoneHuman string `json:"done_human"`
TotalHuman string `json:"total_human"`
}
// resticStatusLine is the subset of restic's `--json` status object we consume. restic emits several
// message_types (status, summary, error, verbose_status); anything that is not "status" is ignored
// here rather than treated as garbage, because restic adds new types between versions and an unknown
// type must never break the run.
type resticStatusLine struct {
MessageType string `json:"message_type"`
PercentDone float64 `json:"percent_done"` // 0..1
TotalBytes int64 `json:"total_bytes"`
BytesDone int64 `json:"bytes_done"`
}
// parseResticStatus parses ONE line of restic --json output into a progress delta.
//
// Kept as a pure function precisely so it can be tested without restic, a network, or a repo — the
// parser is the part that silently rots when restic changes its output, and a progress bar that
// quietly stops moving is worse than no progress bar at all.
func parseResticStatus(line string) (pct float64, done, total int64, ok bool) {
var s resticStatusLine
if err := json.Unmarshal([]byte(line), &s); err != nil {
return 0, 0, 0, false
}
if s.MessageType != "status" {
return 0, 0, 0, false
}
pct = s.PercentDone * 100
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
return pct, s.BytesDone, s.TotalBytes, true
}
// offboxProgressState is the published snapshot, guarded independently of the Manager mutex so a
// poll never blocks behind the running backup.
type offboxProgressState struct {
mu sync.Mutex
cur OffboxProgress
live bool
}
func (p *offboxProgressState) begin() {
p.mu.Lock()
p.cur = OffboxProgress{Active: true}
p.live = true
p.mu.Unlock()
}
func (p *offboxProgressState) end() {
p.mu.Lock()
p.cur = OffboxProgress{}
p.live = false
p.mu.Unlock()
}
func (p *offboxProgressState) setApp(app string) {
p.mu.Lock()
if p.live {
// A new app resets the byte counters: restic's percentages are per-invocation, and carrying
// the previous app's 100% into the next app's start would show a bar that jumps backwards.
p.cur.CurrentApp = app
p.cur.Percent, p.cur.BytesDone, p.cur.TotalBytes = 0, 0, 0
p.cur.DoneHuman, p.cur.TotalHuman = "", ""
}
p.mu.Unlock()
}
func (p *offboxProgressState) update(pct float64, done, total int64) {
p.mu.Lock()
if p.live {
p.cur.Percent, p.cur.BytesDone, p.cur.TotalBytes = pct, done, total
p.cur.DoneHuman, p.cur.TotalHuman = humanizeBytes(done), humanizeBytes(total)
}
p.mu.Unlock()
}
func (p *offboxProgressState) snapshot() OffboxProgress {
p.mu.Lock()
defer p.mu.Unlock()
return p.cur
}
// OffboxProgressSnapshot is the poll surface for the „Távoli mentés" page.
func (m *Manager) OffboxProgressSnapshot() OffboxProgress { return m.offboxProgress.snapshot() }
// offboxStreamRunner is the streaming restic-exec seam: like offboxRunner, but calls onLine for each
// stdout line AS IT ARRIVES instead of only returning the buffered output at exit. Tests inject a
// fake that emits canned `--json` status lines, so the whole progress path is exercised without
// restic, a network or a repo.
type offboxStreamRunner func(ctx context.Context, env []string, onLine func(string), args ...string) ([]byte, error)
// SetOffboxStreamRunner installs the streaming seam (nil → the real streaming exec).
func (m *Manager) SetOffboxStreamRunner(r offboxStreamRunner) { m.offboxStreamRunner = r }
func (m *Manager) streamRunner() offboxStreamRunner {
if m.offboxStreamRunner != nil {
return m.offboxStreamRunner
}
return defaultOffboxStreamRunner
}
// defaultOffboxStreamRunner runs restic with stdout scanned line-by-line. stderr is captured whole
// (restic's --json progress goes to stdout; errors go to stderr) and appended to the returned output
// so callers keep the same error-diagnosis material CombinedOutput gave them.
func defaultOffboxStreamRunner(ctx context.Context, env []string, onLine func(string), args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "restic", args...)
cmd.Env = append(os.Environ(), env...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
var stderr syncBuf
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return nil, err
}
var tail lineTail
scanner := bufio.NewScanner(stdout)
// restic status lines are small, but a --json summary listing many paths can exceed the 64KB
// default; a scanner that dies mid-run would silently freeze the progress bar.
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := scanner.Text()
tail.add(line)
if onLine != nil {
onLine(line)
}
}
_, _ = io.Copy(io.Discard, stdout)
werr := cmd.Wait()
// Keep only the tail of stdout: the full --json stream of a large backup is megabytes of status
// spam, and every caller uses this output for error diagnosis (and lock-pattern matching) only.
out := append(tail.bytes(), stderr.bytes()...)
return out, werr
}
// lineTail keeps the last N lines seen, so error diagnosis has context without buffering the whole
// --json stream.
type lineTail struct {
lines []string
}
func (t *lineTail) add(s string) {
const keep = 40
t.lines = append(t.lines, s)
if len(t.lines) > keep {
t.lines = t.lines[len(t.lines)-keep:]
}
}
func (t *lineTail) bytes() []byte {
var b []byte
for _, l := range t.lines {
b = append(b, l...)
b = append(b, '\n')
}
return b
}
type syncBuf struct {
mu sync.Mutex
b []byte
}
func (s *syncBuf) Write(p []byte) (int, error) {
s.mu.Lock()
s.b = append(s.b, p...)
s.mu.Unlock()
return len(p), nil
}
func (s *syncBuf) bytes() []byte {
s.mu.Lock()
defer s.mu.Unlock()
return append([]byte{}, s.b...)
}
// resticBackupStep is resticStep's streaming twin, used ONLY by the app-backup leg when a manual run
// has a progress sink installed. It keeps resticStep's crash-lock self-heal semantics by delegating
// the retry path to resticStep (a retry after an unlock is rare and does not need progress).
func (m *Manager) resticBackupStep(ctx context.Context, env, base []string, label, app string, args ...string) ([]byte, error) {
if !m.offboxProgress.snapshot().Active {
return m.resticStep(ctx, env, base, label, args...) // nightly / no watcher: unchanged path
}
m.offboxProgress.setApp(app)
full := append(append([]string{}, base...), args...)
// --json turns on the machine-readable progress stream. It is added ONLY here, so the nightly
// run's output format (and everything that greps it) is untouched.
full = append(full, "--json")
out, err := m.streamRunner()(ctx, env, func(line string) {
if pct, done, total, ok := parseResticStatus(line); ok {
m.offboxProgress.update(pct, done, total)
}
}, full...)
if err == nil || !offboxLockRe.Match(out) {
return out, err
}
// Lock collision: fall back to the non-streaming step, which owns the unlock --remove-all
// self-heal. Progress stalls for that one retry; correctness beats a moving bar.
m.logger.Printf("[WARN] [offbox] %s hit a lock during a manual run — retrying via the self-healing step", label)
return m.resticStep(ctx, env, base, label, args...)
}
// beginManualProgress installs the progress sink for a manual run and returns the cleanup func.
func (m *Manager) beginManualProgress() func() {
m.offboxProgress.begin()
started := time.Now()
return func() {
m.logger.Printf("[INFO] [offbox] manual run progress reporting ended after %s", time.Since(started).Round(time.Second))
m.offboxProgress.end()
}
}
@@ -0,0 +1,228 @@
package backup
import (
"context"
"strings"
"testing"
)
// v0.147.0 slice 4c — live progress for a MANUAL offsite run.
//
// These tests exercise the WHOLE path a real run takes: a fake restic emits canned `--json` status
// lines through the streaming seam, and the assertion is on what the poll surface
// (OffboxProgressSnapshot) reports — not on the parser in isolation. A parser that works but is
// never wired to the snapshot would leave the customer looking at the same silent spinner, which is
// the bug being fixed.
//
// RED-PROOF (run manually, both confirmed to fail):
// 1. break the parser — flip `s.MessageType != "status"` to `== "status"` in parseResticStatus:
// TestManualRunReportsParsedProgress fails ("percent = 0, want 42").
// 2. drop the wiring — make resticBackupStep always delegate to resticStep:
// the same test fails (no --json, no stream, no snapshot).
// jsonStatus is one restic --json status line.
func jsonStatus(pct float64, done, total int64) string {
return `{"message_type":"status","percent_done":` + ftoa(pct) + `,"total_bytes":` + itoa(total) + `,"bytes_done":` + itoa(done) + `}`
}
func ftoa(f float64) string {
// small helper — avoids strconv import noise in the canned lines
switch f {
case 0:
return "0"
case 0.42:
return "0.42"
case 1:
return "1"
}
return "0.5"
}
func itoa(i int64) string {
if i == 0 {
return "0"
}
var b []byte
neg := i < 0
if neg {
i = -i
}
for i > 0 {
b = append([]byte{byte('0' + i%10)}, b...)
i /= 10
}
if neg {
return "-" + string(b)
}
return string(b)
}
// TestManualRunReportsParsedProgress is the headline: a manual run driven by a fake restic that emits
// --json status lines must make OffboxProgressSnapshot report the parsed percentage, byte counts and
// the app currently being pushed.
func TestManualRunReportsParsedProgress(t *testing.T) {
e := newSharesOffboxEnv(t, "immich")
if err := e.sett.SetSMBEnabled(false); err != nil { // keep this test to the app leg only
t.Fatal(err)
}
var seen []OffboxProgress
var sawJSONFlag bool
e.m.SetOffboxStreamRunner(func(ctx context.Context, env []string, onLine func(string), args ...string) ([]byte, error) {
for _, a := range args {
if a == "--json" {
sawJSONFlag = true
}
}
// Emit a scan phase (total not yet known), then real progress, sampling the published
// snapshot after each line exactly as the polling page would.
onLine(jsonStatus(0, 0, 0))
seen = append(seen, e.m.OffboxProgressSnapshot())
onLine(`{"message_type":"verbose_status","action":"unchanged"}`) // must be ignored, not fatal
onLine(jsonStatus(0.42, 4200, 10000))
seen = append(seen, e.m.OffboxProgressSnapshot())
return []byte(`{"message_type":"summary","snapshot_id":"abc"}`), nil
})
// The non-streaming seam still serves every other restic call (cat config, forget, snapshots…).
e.m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) {
if contains(args, "snapshots") {
return []byte(`[]`), nil
}
return []byte(""), nil
})
if err := e.m.RunOffboxBackupWithProgress(context.Background()); err != nil {
t.Fatalf("manual run: %v", err)
}
if !sawJSONFlag {
t.Fatal("the manual backup leg did not pass --json to restic — nothing could ever be parsed")
}
if len(seen) != 2 {
t.Fatalf("expected 2 sampled snapshots, got %d", len(seen))
}
// While restic is still scanning, total is unknown: report 0 rather than inventing a percentage.
if seen[0].TotalBytes != 0 || seen[0].Percent != 0 {
t.Errorf("scan phase: got %+v, want zeroed counters", seen[0])
}
if seen[0].CurrentApp != "immich" {
t.Errorf("scan phase: current_app = %q, want %q", seen[0].CurrentApp, "immich")
}
got := seen[1]
if got.Percent != 42 {
t.Errorf("percent = %v, want 42", got.Percent)
}
if got.BytesDone != 4200 || got.TotalBytes != 10000 {
t.Errorf("bytes = %d/%d, want 4200/10000", got.BytesDone, got.TotalBytes)
}
if got.CurrentApp != "immich" {
t.Errorf("current_app = %q, want %q", got.CurrentApp, "immich")
}
if got.TotalHuman == "" || got.DoneHuman == "" {
t.Errorf("humanized byte strings not populated: %+v", got)
}
if !got.Active {
t.Error("progress reported inactive during a manual run")
}
// After the run the sink must be torn down, or the page would keep rendering a stale bar.
if after := e.m.OffboxProgressSnapshot(); after.Active || after.Percent != 0 {
t.Errorf("progress still published after the run: %+v", after)
}
}
// TestNightlyRunStaysSilent pins the scope decision: the scheduled run must neither install the sink
// nor pass --json, so its output format (and everything that greps it) is untouched.
func TestNightlyRunStaysSilent(t *testing.T) {
e := newSharesOffboxEnv(t, "immich")
if err := e.sett.SetSMBEnabled(false); err != nil {
t.Fatal(err)
}
e.m.SetOffboxStreamRunner(func(ctx context.Context, env []string, onLine func(string), args ...string) ([]byte, error) {
t.Fatal("the nightly run used the streaming runner — progress must be manual-only")
return nil, nil
})
var backupArgs []string
e.m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) {
if contains(args, "backup") {
backupArgs = append([]string{}, args...)
}
if contains(args, "snapshots") {
return []byte(`[]`), nil
}
return []byte(""), nil
})
if err := e.m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("nightly run: %v", err)
}
if backupArgs == nil {
t.Fatal("no backup call was made")
}
if contains(backupArgs, "--json") {
t.Errorf("the nightly run passed --json: %v", backupArgs)
}
if p := e.m.OffboxProgressSnapshot(); p.Active {
t.Errorf("the nightly run published progress: %+v", p)
}
}
// TestParseResticStatusIgnoresNonStatus covers the lines restic actually interleaves with status
// output. An unknown message_type must be ignored, never mistaken for progress — restic adds new
// types between versions.
func TestParseResticStatusIgnoresNonStatus(t *testing.T) {
for _, line := range []string{
`{"message_type":"summary","total_bytes_processed":123}`,
`{"message_type":"verbose_status","action":"new"}`,
`{"message_type":"error","error":{"message":"boom"}}`,
`not json at all`,
``,
`{}`,
} {
if _, _, _, ok := parseResticStatus(line); ok {
t.Errorf("parsed a non-status line as progress: %q", line)
}
}
pct, done, total, ok := parseResticStatus(jsonStatus(0.42, 4200, 10000))
if !ok {
t.Fatal("a real status line was not parsed")
}
if pct != 42 || done != 4200 || total != 10000 {
t.Errorf("got %v %d %d, want 42 4200 10000", pct, done, total)
}
}
// TestParseResticStatusClampsPercent — restic has been seen to report percent_done slightly above 1
// near completion. A bar wider than its track is a visible bug.
func TestParseResticStatusClampsPercent(t *testing.T) {
pct, _, _, ok := parseResticStatus(`{"message_type":"status","percent_done":1.04}`)
if !ok || pct != 100 {
t.Errorf("percent = %v (ok=%v), want clamped to 100", pct, ok)
}
pct, _, _, ok = parseResticStatus(`{"message_type":"status","percent_done":-0.2}`)
if !ok || pct != 0 {
t.Errorf("percent = %v (ok=%v), want clamped to 0", pct, ok)
}
}
// TestLineTailKeepsOnlyTheTail — the --json stream of a large backup is megabytes of status spam, and
// every caller uses this output for error diagnosis and lock-pattern matching. Buffering all of it
// would be a memory leak proportional to backup size.
func TestLineTailKeepsOnlyTheTail(t *testing.T) {
var tl lineTail
for i := 0; i < 500; i++ {
tl.add("line-" + itoa(int64(i)))
}
out := string(tl.bytes())
if strings.Contains(out, "line-0\n") {
t.Error("the oldest line survived — the tail is unbounded")
}
if !strings.Contains(out, "line-499") {
t.Error("the newest line was dropped")
}
if n := strings.Count(out, "\n"); n > 64 {
t.Errorf("tail kept %d lines, want a small bounded number", n)
}
}
+3 -2
View File
@@ -128,9 +128,10 @@ func (m *Manager) offboxSnapshotSize(ctx context.Context, id string) (int64, err
// probe). NEVER cfg.Paths.DataDir (the rootfs — the F-A1 filler). App's HDD drive first; else the first
// schedulable storage path; else a Hungarian refusal.
func (m *Manager) offboxRestoreScratchDir(stack string) (scratch, nsRoot string, err error) {
// offsiteRestoreRootFor is THE place `backups/offsite-restore` is spelled (offbox_verify_copies.go)
// — the listing/delete surface must resolve byte-identical paths to the ones written here.
scratchFor := func(root string) (string, string) {
nr := m.namespaceRoot(root)
return filepath.Join(nr, "backups", "offsite-restore", stack), nr
return filepath.Join(m.offsiteRestoreRootFor(root), stack), m.namespaceRoot(root)
}
isNet := func(path string) bool { return m.settings != nil && m.settings.IsNetworkStoragePath(path) }
// (1) the app's own drive — preferred, but ONLY if it is not NETWORK storage (F-3afix-1). restic
@@ -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
}
@@ -0,0 +1,181 @@
package backup
import (
"os"
"path/filepath"
"testing"
)
// v0.147.0 slice 4a — the verification-copy listing/delete surface.
//
// DeleteOffsiteRestoreCopy is the ONLY delete this slice adds, so these tests are about what it must
// REFUSE as much as what it must do. Every refusal is asserted as a NON-EFFECT: the neighbouring copy
// and the customer's live data must still be on disk afterwards. A guard that returns an error but
// deletes anyway passes a naive test and loses data.
//
// RED-PROOF (run manually, confirmed): neutralise the isSafeStackName check in
// DeleteOffsiteRestoreCopy and TestDeleteVerifyCopyRefusesUnsafeNames fails hard — `stack: ""`
// resolves to the offsite-restore ROOT and os.RemoveAll takes every verification copy with it. That
// is the failure this guard exists to prevent, and it is data loss, not a bad error message.
//
// The HasPrefix containment check inside DeleteOffsiteRestoreCopy could NOT be red-proofed
// independently: with isSafeStackName in front of it, no input this API accepts can reach it with an
// escaping path, so removing it leaves every test green (and the variable unused). It is deliberate
// defence-in-depth against a future caller or a refactor that loosens the name check — kept, but
// honestly labelled here as unproven-by-test rather than pretending to a red-proof it does not have.
// verifyCopyEnv wires a manager with one drive and materialised verification copies.
type verifyCopyEnv struct {
m *Manager
drive string
root string // <nsRoot>/backups/offsite-restore
}
func newVerifyCopyEnv(t *testing.T, copies ...string) *verifyCopyEnv {
t.Helper()
drive := t.TempDir()
m, _, _ := classifiedOffboxManager(t, drive)
root := m.offsiteRestoreRootFor(drive)
for _, c := range copies {
p := filepath.Join(root, c)
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(p, "payload.bin"), []byte("restored bytes"), 0o644); err != nil {
t.Fatal(err)
}
}
return &verifyCopyEnv{m: m, drive: drive, root: root}
}
func TestListOffsiteRestoreCopiesReportsPathAndSize(t *testing.T) {
e := newVerifyCopyEnv(t, "immich", "nextcloud")
got := e.m.ListOffsiteRestoreCopies()
if len(got) != 2 {
t.Fatalf("listed %d copies, want 2: %+v", len(got), got)
}
byStack := map[string]OffsiteRestoreCopy{}
for _, c := range got {
byStack[c.Stack] = c
}
for _, name := range []string{"immich", "nextcloud"} {
c, ok := byStack[name]
if !ok {
t.Fatalf("%s missing from the listing", name)
}
// THE POINT of the listing: the customer could not previously see WHERE the copy was.
want := filepath.Join(e.root, name)
if c.Path != want {
t.Errorf("%s path = %q, want %q", name, c.Path, want)
}
if c.SizeHuman == "" {
t.Errorf("%s has no humanized size", name)
}
if c.Created.IsZero() {
t.Errorf("%s has no creation time", name)
}
}
}
func TestListOffsiteRestoreCopiesEmptyIsNotAnError(t *testing.T) {
// No offsite-restore directory at all — the normal state on a box that never ran a verification
// restore. Must be an empty list, not a crash and not a phantom entry.
e := newVerifyCopyEnv(t)
if got := e.m.ListOffsiteRestoreCopies(); len(got) != 0 {
t.Errorf("listed %d copies on a clean box, want 0: %+v", len(got), got)
}
}
func TestDeleteVerifyCopyRemovesOnlyTheNamedOne(t *testing.T) {
e := newVerifyCopyEnv(t, "immich", "nextcloud")
// Live customer data next to the sandbox — must be untouched by any delete.
live := filepath.Join(e.drive, "immich-live")
if err := os.MkdirAll(live, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(live, "photo.jpg"), []byte("irreplaceable"), 0o644); err != nil {
t.Fatal(err)
}
if err := e.m.DeleteOffsiteRestoreCopy("immich"); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := os.Stat(filepath.Join(e.root, "immich")); !os.IsNotExist(err) {
t.Error("the named copy survived the delete")
}
if _, err := os.Stat(filepath.Join(e.root, "nextcloud", "payload.bin")); err != nil {
t.Errorf("a NEIGHBOURING copy was destroyed: %v", err)
}
if _, err := os.Stat(filepath.Join(live, "photo.jpg")); err != nil {
t.Errorf("LIVE CUSTOMER DATA was destroyed: %v", err)
}
}
func TestDeleteVerifyCopyRefusesUnsafeNames(t *testing.T) {
e := newVerifyCopyEnv(t, "immich")
// Something outside the sandbox that a traversal would reach.
outside := filepath.Join(e.drive, "backups", "primary")
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "unit.tar"), []byte("recovery unit"), 0o644); err != nil {
t.Fatal(err)
}
for _, bad := range []string{
"../primary",
"../../..",
"..",
"immich/../../primary",
"/etc",
"",
} {
if err := e.m.DeleteOffsiteRestoreCopy(bad); err == nil {
t.Errorf("delete(%q) was ACCEPTED — it must be refused", bad)
}
// The refusal must also be a NON-EFFECT.
if _, err := os.Stat(filepath.Join(outside, "unit.tar")); err != nil {
t.Fatalf("delete(%q) destroyed data outside the sandbox: %v", bad, err)
}
if _, err := os.Stat(filepath.Join(e.root, "immich", "payload.bin")); err != nil {
t.Fatalf("delete(%q) destroyed an unrelated copy: %v", bad, err)
}
}
}
func TestDeleteVerifyCopyStaysInsideTheSandbox(t *testing.T) {
e := newVerifyCopyEnv(t, "immich")
// A well-formed name that simply does not exist must be an error, never a silent success that
// could mask a path-resolution bug.
if err := e.m.DeleteOffsiteRestoreCopy("no-such-app"); err == nil {
t.Error("deleting a non-existent copy reported success")
}
// Everything under the sandbox root must resolve strictly inside it.
for _, c := range e.m.ListOffsiteRestoreCopies() {
rel, err := filepath.Rel(e.root, c.Path)
if err != nil || rel == ".." || filepath.IsAbs(rel) || len(rel) > 2 && rel[:2] == ".." {
t.Errorf("listed copy %q resolves outside %q", c.Path, e.root)
}
}
}
// TestOffsiteRestoreScratchPathMatchesTheListing pins the two halves together: the path the UI names
// in the completion flash must be the same path the listing (and therefore the delete button) uses.
// If these ever diverge, the customer is told about a directory the page cannot show or remove.
func TestOffsiteRestoreScratchPathMatchesTheListing(t *testing.T) {
e := newVerifyCopyEnv(t, "immich")
named := e.m.OffsiteRestoreScratchPath("immich")
if named == "" {
t.Fatal("OffsiteRestoreScratchPath returned empty — the flash would fall back to the vague wording")
}
var listed string
for _, c := range e.m.ListOffsiteRestoreCopies() {
if c.Stack == "immich" {
listed = c.Path
}
}
if named != listed {
t.Errorf("flash names %q but the listing shows %q", named, listed)
}
}