v0.297.0: burn-down round 2 — 24 small rows (R-591 R-568 R-567 R-363 R-547 R-10 R-552 R-251 R-104 R-619 R-362 R-675 R-256 R-257 R-240 R-365 R-425 R-565 R-564 R-603 R-454 R-208 R-457-swept) + the banner countdown and deepCopyStack twins; MinAgent 0.131.0
gates / gates (push) Failing after 50s
gates / gates (push) Failing after 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -155,6 +155,9 @@ type Manager struct {
|
||||
// offboxFreeFn (3a) — the free-space probe for the restore headroom gate, overridable in tests (the
|
||||
// Windows `go test` host has no `df`). Nil → the real diskFreeBytes (df --output=avail).
|
||||
offboxFreeFn func(path string) int64
|
||||
// driveMountedFn (R-362) answers "is this registered drive still mounted?" when a restore cannot
|
||||
// create its directory. nil → system.IsMountPoint. Tests inject it; production never sets it.
|
||||
driveMountedFn func(path string) bool
|
||||
|
||||
// offboxLatestSnapFn (R-357) overrides the restic snapshot lookup, and it exists for one reason:
|
||||
// without it, ReconstituteFromOffsite's new headroom gate cannot be tested at the level that
|
||||
|
||||
@@ -148,8 +148,17 @@ const (
|
||||
OffsiteFailNoUnits OffsiteFailureClass = "no_units" // apps toggled but no recovery unit found on any drive
|
||||
OffsiteFailTransport OffsiteFailureClass = "transport" // network / SFTP auth / host key / timeout
|
||||
OffsiteFailUnknown OffsiteFailureClass = "unknown" // genuinely unclassified — say so rather than guess
|
||||
// OffsiteFailLocked (R-104): the repository is held by a lock that survived both self-heal layers
|
||||
// (pre-run stale unlock, then resticStep's unlock --remove-all + one retry). Known and named, not
|
||||
// "unknown" — an interrupted earlier run is the usual cause.
|
||||
OffsiteFailLocked OffsiteFailureClass = "locked"
|
||||
)
|
||||
|
||||
// ErrOffsiteLocked marks a restic step that still found the repository locked after resticStep's
|
||||
// self-heal (R-104). resticStep wraps it into the step's error, because the lock text lives in the
|
||||
// command OUTPUT, not in the exit error, and would never reach ClassifyOffsiteFailure otherwise.
|
||||
var ErrOffsiteLocked = errors.New("offsite repository is still locked after the self-heal")
|
||||
|
||||
// offsiteRepoURLRe matches the `sftp:user@host:/path` repo reference restic echoes back in its errors.
|
||||
// It is the BACKSTOP, not the primary defence — see sanitiseOffsiteErrorFor.
|
||||
var offsiteRepoURLRe = regexp.MustCompile(`sftp:[^\s"']+`)
|
||||
@@ -203,6 +212,12 @@ func ClassifyOffsiteFailure(err error) OffsiteFailureClass {
|
||||
if errors.Is(err, ErrOffsiteQuota) {
|
||||
return OffsiteFailQuota
|
||||
}
|
||||
// R-104 — before the transport signatures: restic's lock error is a precisely known cause. The
|
||||
// sentinel is what resticStep attaches; the text match catches an error that carries restic's
|
||||
// own output. Pinned by TestR104_SurvivingLockIsNamed.
|
||||
if errors.Is(err, ErrOffsiteLocked) || offboxLockRe.MatchString(err.Error()) {
|
||||
return OffsiteFailLocked
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(s, "produced no snapshots"):
|
||||
@@ -248,6 +263,7 @@ func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duratio
|
||||
OffsiteFailNoUnits: util.Text(lang, "note.offsite.fail_no_units"),
|
||||
OffsiteFailTransport: util.Text(lang, "note.offsite.fail_transport"),
|
||||
OffsiteFailUnknown: util.Text(lang, "note.offsite.fail_unknown"),
|
||||
OffsiteFailLocked: util.Text(lang, "note.offsite.fail_locked"),
|
||||
}[ClassifyOffsiteFailure(err)]
|
||||
if head == "" {
|
||||
head = util.Text(lang, "note.offsite.fail_head")
|
||||
@@ -855,10 +871,14 @@ func (m *Manager) resticStep(ctx context.Context, env, base []string, label stri
|
||||
if uout, uerr := m.runner()(uctx, env, append(append([]string{}, base...), "unlock", "--remove-all")...); uerr != nil {
|
||||
cancel()
|
||||
m.logger.Printf("[WARN] [offbox] unlock --remove-all failed: %v: %s", uerr, truncate(uout))
|
||||
return out, err // surface the original lock error (never loop)
|
||||
return out, fmt.Errorf("%w (%w)", err, ErrOffsiteLocked) // surface the original lock error (never loop); R-104: named
|
||||
}
|
||||
cancel()
|
||||
return m.runner()(ctx, env, full...) // retry exactly ONCE
|
||||
rout, rerr := m.runner()(ctx, env, full...) // retry exactly ONCE
|
||||
if rerr != nil && offboxLockRe.Match(rout) {
|
||||
return rout, fmt.Errorf("%w (%w)", rerr, ErrOffsiteLocked) // R-104: still locked after the self-heal
|
||||
}
|
||||
return rout, rerr
|
||||
}
|
||||
|
||||
// ensureOffboxRepo makes sure the SFTP repo exists: probe `cat config`; if absent, `init` (idempotent —
|
||||
@@ -1164,8 +1184,13 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
// is covered until at least one app is toggled.
|
||||
// R-7b: the shares leg counts as coverage — a box whose only cloud content is its shares
|
||||
// must not be told "nothing is selected".
|
||||
// R-240: the sentence no longer opens with „Sikeres" — a run that covered nothing was
|
||||
// congratulating the household on it. The verdict stays `ok` (an unconfigured selection is
|
||||
// not a failure). Still a Hungarian LITERAL and still carrying the lowercase marker
|
||||
// „nincs mentésre jelölt alkalmazás" (R-570: boxes with the older persisted text are
|
||||
// recognised by those words). Pinned by TestR240_ZeroSelectionRunDoesNotSaySuccess.
|
||||
if len(apps) == 0 && !runResult.sharesBackedUp {
|
||||
warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")
|
||||
warns = append(warns, "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás")
|
||||
warnKind = OffboxWarnNoAppsSelected // R-553: the page reads this, not the sentence
|
||||
}
|
||||
// R-234 §7.4 — WHICH apps, WHY, and WHEN. The old sentence said only that N apps "had no
|
||||
@@ -1926,7 +1951,7 @@ func (m *Manager) RestoreOffbox(ctx context.Context, stackName, destDir string)
|
||||
return fmt.Errorf("invalid stack name")
|
||||
}
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
return fmt.Errorf("restore dir: %w", err)
|
||||
return m.restoreDirError(destDir, err)
|
||||
}
|
||||
t := m.settings.GetOffboxTarget()
|
||||
base, env := m.offboxBaseArgs(t)
|
||||
|
||||
@@ -61,6 +61,11 @@ type offsiteNewest struct {
|
||||
paths []string
|
||||
}
|
||||
|
||||
// OffboxMarkerTag is on EVERY off-site snapshot (`restic backup --tag felhom-offbox --tag <stack>`),
|
||||
// beside the app's own tag. It marks the tier, it does not name an app, so no per-app view may key
|
||||
// on it (R-251). Pinned by TestR251_MarkerTagIsNotAnApp.
|
||||
const OffboxMarkerTag = "felhom-offbox"
|
||||
|
||||
// offsiteNewestPerTag runs ONE `snapshots --json` and returns the newest snapshot per app tag, and
|
||||
// whether the repository opened cleanly and holds no snapshots at all. Shared by the inventory page
|
||||
// and the update precondition (R-477), so the two cannot disagree about what is in the repository.
|
||||
@@ -100,7 +105,10 @@ func (m *Manager) offsiteNewestPerTag(ctx context.Context) (map[string]offsiteNe
|
||||
id = sn.ID
|
||||
}
|
||||
for _, tag := range sn.Tags {
|
||||
if tag == "" {
|
||||
if tag == "" || tag == OffboxMarkerTag {
|
||||
// R-251: the marker is on EVERY off-site snapshot; it is not an app. Keyed here it
|
||||
// became a second row on the recovery listing — a stranger's name beside the
|
||||
// customer's app and their data counted twice.
|
||||
continue
|
||||
}
|
||||
if cur, ok := newest[tag]; !ok || sn.Time.After(cur.at) {
|
||||
|
||||
@@ -377,7 +377,7 @@ func (m *Manager) RestoreOffboxScratch(ctx context.Context, stack string, full b
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(scratch, 0o755); err != nil {
|
||||
return fmt.Errorf("restore dir: %w", err)
|
||||
return m.restoreDirError(scratch, err)
|
||||
}
|
||||
// R-358: a marker from a PREVIOUS run must never certify this one. Cleared here, before restic
|
||||
// touches anything, so the window in which a stale certificate could vouch for a part-copy does not
|
||||
|
||||
@@ -195,7 +195,7 @@ func TestOffbox_RunFailsFastAndAlerts(t *testing.T) {
|
||||
}
|
||||
|
||||
// Zero-toggle honesty (take-two obs., v0.123.0): a run with a configured+escrowed target but ZERO
|
||||
// toggled apps must stay status=ok yet report "Sikeres — nincs mentésre jelölt alkalmazás" instead
|
||||
// toggled apps must stay status=ok yet report "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás" instead
|
||||
// of a bare success. Red-proof: drop the len(apps)==0 warns branch → the LastWarning assertion fails.
|
||||
func TestOffbox_ZeroToggledRunReportsEmptiness(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
@@ -213,7 +213,7 @@ func TestOffbox_ZeroToggledRunReportsEmptiness(t *testing.T) {
|
||||
if st.LastStatus != "ok" {
|
||||
t.Fatalf("zero-toggled run status = %q, want ok (emptiness is honesty, not failure)", st.LastStatus)
|
||||
}
|
||||
if !strings.Contains(st.LastWarning, "Sikeres — nincs mentésre jelölt alkalmazás") {
|
||||
if !strings.Contains(st.LastWarning, "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás") {
|
||||
t.Fatalf("zero-toggled run must report its emptiness, LastWarning = %q", st.LastWarning)
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ func backupNoteHU(t *testing.T, key string) string {
|
||||
//
|
||||
// RED-PROOF (REPORT): turn the producer into m.note("…") → this test fails naming the line.
|
||||
func TestR570SentenceStaysHungarian(t *testing.T) {
|
||||
const sentence = "Sikeres — nincs mentésre jelölt alkalmazás"
|
||||
const sentence = "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás"
|
||||
src, err := os.ReadFile("offbox.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-104: a restic lock that survives BOTH self-heal layers was reported as „ismeretlen okból" —
|
||||
// the class had no lock case, and the lock text lives in restic's OUTPUT, never in the exit error,
|
||||
// so the text classifier could not have seen it anyway. The consequence asserted, through the real
|
||||
// resticStep with a scripted runner: the failure the run would report is classed Locked, and the
|
||||
// message names the lock in both languages instead of admitting an unknown cause.
|
||||
func TestR104_SurvivingLockIsNamed(t *testing.T) {
|
||||
m, _ := newOffboxManager(t)
|
||||
locked := []byte("Fatal: unable to create lock in backend: repository is already locked exclusively by PID 77 on felhom-controller\n")
|
||||
calls := 0
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
calls++
|
||||
if contains(args, "unlock") {
|
||||
return nil, nil // the remove-all "succeeds", and the lock is still there on the retry
|
||||
}
|
||||
return locked, fmt.Errorf("exit status 1")
|
||||
})
|
||||
_, err := m.resticStep(context.Background(), nil, []string{"-r", "repo"}, "backup:app", "backup", "/x")
|
||||
if err == nil {
|
||||
t.Fatal("setup: the scripted lock did not fail the step")
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("setup: want step + unlock --remove-all + one retry (3 calls), got %d", calls)
|
||||
}
|
||||
runErr := fmt.Errorf("offbox backup app: %w", err) // exactly how the run wraps a step failure
|
||||
if got := ClassifyOffsiteFailure(runErr); got != OffsiteFailLocked {
|
||||
t.Errorf("R-104: a lock that survived the self-heal is classed %q, want %q", got, OffsiteFailLocked)
|
||||
}
|
||||
hu := offsiteFailureMessage(diagTarget(), runErr, time.Minute, "hu")
|
||||
if strings.Contains(hu, "ismeretlen okb") || !strings.Contains(hu, "zárva hagyta") {
|
||||
t.Errorf("R-104: the Hungarian message does not name the lock: %q", hu)
|
||||
}
|
||||
en := offsiteFailureMessage(diagTarget(), runErr, time.Minute, "en")
|
||||
if strings.Contains(en, "unknown reason") || !strings.Contains(en, "still locked") {
|
||||
t.Errorf("R-104: the English message does not name the lock: %q", en)
|
||||
}
|
||||
|
||||
// The text path: an error that carries restic's own output is named too.
|
||||
if got := ClassifyOffsiteFailure(fmt.Errorf("restic: %s", locked)); got != OffsiteFailLocked {
|
||||
t.Errorf("R-104: restic's lock text is classed %q, want %q", got, OffsiteFailLocked)
|
||||
}
|
||||
// Negative control: an unrelated failure is still honest about being unknown.
|
||||
if got := ClassifyOffsiteFailure(fmt.Errorf("something entirely new")); got != OffsiteFailUnknown {
|
||||
t.Errorf("an unrelated failure became %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-240: an off-site run with no app selected reported „Sikeres — nincs mentésre jelölt alkalmazás":
|
||||
// SUCCESSFUL right beside NOTHING SELECTED, the same shape as R-203/R-234 (a warning beside a success
|
||||
// is read as a success). The consequence asserted through the real run: the verdict stays `ok` (an
|
||||
// empty selection is not a failure), the note says plainly that the run saved nothing, it does NOT
|
||||
// say „Sikeres", and it still carries the lowercase marker the R-570 legacy fallback reads.
|
||||
func TestR240_ZeroSelectionRunDoesNotSaySuccess(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
if contains(args, "cat") && contains(args, "config") {
|
||||
return []byte(`{"version":2}`), nil
|
||||
}
|
||||
return []byte(`[]`), nil
|
||||
})
|
||||
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
||||
t.Fatalf("zero-selection run must not error: %v", err)
|
||||
}
|
||||
st := sett.GetOffboxTarget()
|
||||
if st.LastStatus != "ok" {
|
||||
t.Errorf("verdict = %q, want ok — an empty selection is not a failure", st.LastStatus)
|
||||
}
|
||||
if strings.Contains(st.LastWarning, "Sikeres") {
|
||||
t.Errorf("R-240: the note for a run that saved nothing still calls itself successful: %q", st.LastWarning)
|
||||
}
|
||||
if !strings.Contains(st.LastWarning, "semmit nem mentett") {
|
||||
t.Errorf("R-240: the note does not say the run saved nothing: %q", st.LastWarning)
|
||||
}
|
||||
if !strings.Contains(st.LastWarning, "nincs mentésre jelölt alkalmazás") {
|
||||
t.Errorf("the R-570 marker is gone from the note: %q", st.LastWarning)
|
||||
}
|
||||
if st.LastWarningKind != OffboxWarnNoAppsSelected {
|
||||
t.Errorf("the kind is not recorded: %q", st.LastWarningKind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-251: every off-site snapshot carries the tier's marker tag beside the app's own. The recovery
|
||||
// listing keyed rows on every tag, so the customer saw TWO rows for one app — `calibre-web` and
|
||||
// `felhom-offbox`, each 12.8 MB — a stranger's name and their data counted twice. The consequence
|
||||
// asserted: the listing holds exactly the app, and only ONE size call is spent (the marker costs no
|
||||
// round-trip to the storage box).
|
||||
func TestR251_MarkerTagIsNotAnApp(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
||||
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
|
||||
Schedule: "daily", EscrowState: "escrowed",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stats int32
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
if contains(args, "snapshots") {
|
||||
return []byte(`[{"short_id":"snap0","time":"2026-08-07T14:57:00Z","tags":["felhom-offbox","calibre-web"]}]`), nil
|
||||
}
|
||||
if contains(args, "stats") {
|
||||
atomic.AddInt32(&stats, 1)
|
||||
return []byte(`{"total_size":13421772}`), nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
inv, err := m.OffsiteInventoryList(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, a := range inv.Apps {
|
||||
names = append(names, a.App)
|
||||
}
|
||||
if len(names) != 1 || names[0] != "calibre-web" {
|
||||
t.Errorf("R-251: the recovery listing shows %v; want only [calibre-web] — the marker tag is not an app", names)
|
||||
}
|
||||
if n := atomic.LoadInt32(&stats); n != 1 {
|
||||
t.Errorf("R-251: %d size calls for one app, want 1", n)
|
||||
}
|
||||
|
||||
times, err := m.OffsiteSnapshotTimes(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := times[OffboxMarkerTag]; ok {
|
||||
t.Errorf("R-251: the marker tag is reported as an app with a snapshot time")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// R-362: a data drive unbound 4 s into a scratch restore was reported to the household as
|
||||
// „restore dir: mkdir /mnt/felhom-drives/hdd_1/backups: permission denied" — a correct refusal that
|
||||
// misdescribed why. The consequence asserted: what the household reads (util.ErrText, the renderer
|
||||
// the restore notice uses) names the DRIVE, in both languages, and no longer says "permission
|
||||
// denied" — while a genuine permission problem on a drive that IS mounted keeps its own wording.
|
||||
func TestR362_DetachedDriveIsNamed(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
managed := filepath.Join(settings.NetworkMountRoot, "hdd_1")
|
||||
legacy := "/mnt/hdd_legacy"
|
||||
for _, sp := range []settings.StoragePath{
|
||||
{Path: managed, Label: "Kulso HDD"},
|
||||
{Path: legacy, Label: "Regi HDD", Disconnected: true},
|
||||
} {
|
||||
if err := sett.AddStoragePath(sp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mounted := false
|
||||
m.driveMountedFn = func(string) bool { return mounted }
|
||||
eacces := func(p string) error { return &fs.PathError{Op: "mkdir", Path: p, Err: syscall.EACCES} }
|
||||
scratch := filepath.Join(managed, "backups", "offsite-restore", "app")
|
||||
|
||||
// 1. The managed drive is no longer a mount point (the flag has not caught up yet).
|
||||
err := m.restoreDirError(scratch, eacces(filepath.Join(managed, "backups")))
|
||||
hu, en := util.ErrText("hu", err), util.ErrText("en", err)
|
||||
if strings.Contains(hu, "permission denied") || !strings.Contains(hu, "Kulso HDD") || !strings.Contains(hu, "nem érhető el") {
|
||||
t.Errorf("R-362: the Hungarian refusal does not name the missing drive: %q", hu)
|
||||
}
|
||||
if strings.Contains(en, "permission denied") || !strings.Contains(en, "Kulso HDD") || !strings.Contains(en, "cannot be reached") {
|
||||
t.Errorf("R-362: the English refusal does not name the missing drive: %q", en)
|
||||
}
|
||||
|
||||
// 2. A legacy path outside the managed parent, flagged disconnected by the drive gate.
|
||||
if got := util.ErrText("en", m.restoreDirError(filepath.Join(legacy, "x"), eacces(legacy))); !strings.Contains(got, "Regi HDD") {
|
||||
t.Errorf("R-362: a drive the registry marks disconnected is not named: %q", got)
|
||||
}
|
||||
|
||||
// 3. Negative control: the drive IS mounted, so a permission error is a real permission error.
|
||||
mounted = true
|
||||
if got := util.ErrText("en", m.restoreDirError(scratch, eacces(scratch))); !strings.Contains(got, "permission denied") {
|
||||
t.Errorf("a genuine permission error on a mounted drive must keep its wording, got %q", got)
|
||||
}
|
||||
// 4. Negative control: a different failure class is never re-labelled.
|
||||
mounted = false
|
||||
if got := util.ErrText("en", m.restoreDirError(scratch, &fs.PathError{Op: "mkdir", Path: scratch, Err: syscall.EIO})); strings.Contains(got, "cannot be reached") {
|
||||
t.Errorf("an I/O error was re-labelled as a missing drive: %q", got)
|
||||
}
|
||||
|
||||
// Wiring: every restore that creates its directory goes through restoreDirError — no bare
|
||||
// "restore dir: %w" is left at a call site.
|
||||
for _, f := range []string{"offbox_restore.go", "offbox.go", "shares_restore.go"} {
|
||||
src, rerr := os.ReadFile(f)
|
||||
if rerr != nil {
|
||||
t.Fatal(rerr)
|
||||
}
|
||||
if strings.Contains(string(src), `fmt.Errorf("restore dir: %w", err)`) {
|
||||
t.Errorf("R-362: %s still returns the raw restore-dir error", f)
|
||||
}
|
||||
if !strings.Contains(string(src), "m.restoreDirError(") {
|
||||
t.Errorf("R-362: %s does not call restoreDirError", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func TestR553_OffboxRunRecordsTheKind(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(src)
|
||||
i := strings.Index(body, `warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")`)
|
||||
i := strings.Index(body, `warns = append(warns, "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás")`)
|
||||
if i < 0 {
|
||||
t.Fatal("the zero-selection sentence is gone from the run — this test no longer reads what it thinks it reads")
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestR669_UnitCapturesThePinnedVersionsMeta(t *testing.T) {
|
||||
drive := filepath.Join(tmp, "drive")
|
||||
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), "services:\n app:\n image: example/app:1.2.3\n")
|
||||
mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: Example\nhealthcheck:\n checks:\n - type: api\n port: 8999\n") // flowed by the sync
|
||||
mustWrite(t, stacks.AppliedMetaFile(stackDir), "display_name: Example\nhealthcheck:\n checks:\n - type: api\n port: 80\n") // the pinned version's
|
||||
mustWrite(t, stacks.AppliedMetaFile(stackDir), "display_name: Example\nhealthcheck:\n checks:\n - type: api\n port: 80\n") // the pinned version's
|
||||
mustWrite(t, filepath.Join(stackDir, "app.yaml"), "deployed: true\nenv: {}\n")
|
||||
m := &Manager{
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// restoreDirError (R-362) turns a failure to create a restore's directory into the truth when the
|
||||
// cause is a drive that went away. Measured 2026-08-21: the guest-visible bind was unmounted 4 s into
|
||||
// a scratch restore and the household read „restore dir: mkdir /mnt/felhom-drives/hdd_1/backups:
|
||||
// permission denied" — a correct refusal that sent the reader after a permissions problem that did
|
||||
// not exist. The settings flag can lag a detach by seconds, so the drive is also checked directly:
|
||||
// a drive under the managed parent that is no longer a mount point is gone, whatever the flag says.
|
||||
// Any other failure keeps its original wording. Pinned by TestR362_DetachedDriveIsNamed.
|
||||
func (m *Manager) restoreDirError(dir string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrPermission) && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("restore dir: %w", err)
|
||||
}
|
||||
sp, ok := m.registeredDriveHolding(dir)
|
||||
if !ok || !m.driveGone(sp) {
|
||||
return fmt.Errorf("restore dir: %w", err)
|
||||
}
|
||||
name := sp.Label
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = sp.Path
|
||||
}
|
||||
m.logger.Printf("[WARN] [backup] restore dir %s: %v — the drive %s (%s) is not connected; reported as a missing drive (R-362)", dir, err, sp.Path, name)
|
||||
return util.MsgError("err.backup.restore_drive_gone", name)
|
||||
}
|
||||
|
||||
// registeredDriveHolding returns the registered storage path that contains dir (longest match).
|
||||
func (m *Manager) registeredDriveHolding(dir string) (settings.StoragePath, bool) {
|
||||
if m == nil || m.settings == nil {
|
||||
return settings.StoragePath{}, false
|
||||
}
|
||||
clean := filepath.Clean(dir)
|
||||
var best settings.StoragePath
|
||||
found := false
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
root := filepath.Clean(sp.Path)
|
||||
if root == "" || root == "/" {
|
||||
continue
|
||||
}
|
||||
if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) {
|
||||
if !found || len(root) > len(filepath.Clean(best.Path)) {
|
||||
best, found = sp, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
// driveGone: the registry already says disconnected, or the drive lives under the managed parent
|
||||
// (where every enrolled drive is a bind mount) and is no longer a mount point.
|
||||
func (m *Manager) driveGone(sp settings.StoragePath) bool {
|
||||
if sp.Disconnected {
|
||||
return true
|
||||
}
|
||||
parent := filepath.Clean(settings.NetworkMountRoot) + string(filepath.Separator)
|
||||
if !strings.HasPrefix(filepath.Clean(sp.Path)+string(filepath.Separator), parent) {
|
||||
return false // a legacy path outside the managed parent: only the flag can say it is gone
|
||||
}
|
||||
mounted := system.IsMountPoint
|
||||
if m.driveMountedFn != nil {
|
||||
mounted = m.driveMountedFn
|
||||
}
|
||||
return !mounted(sp.Path)
|
||||
}
|
||||
@@ -133,3 +133,22 @@ func (m *Manager) persistRestoreRecordLocked() {
|
||||
m.logger.Printf("[WARN] [backup] could not persist the restore record to %s: %v (kept in memory)", m.opRecordPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearInterruptedRestore drops an app's standing interrupted-restore notice and persists the record
|
||||
// (R-552). Called when the app is REMOVED: the notice asks the household to run the restore again,
|
||||
// and for an app that no longer exists that is a card about nothing, shown for ever — before this,
|
||||
// only a new restore of the same app (BeginRestoreOp) ever cleared it. Reports whether a notice was
|
||||
// there. Pinned by TestClearInterruptedRestore_DropsTheNoticeAndPersists.
|
||||
func (m *Manager) ClearInterruptedRestore(stack string) bool {
|
||||
if m == nil || stack == "" {
|
||||
return false
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.opInterrupted[stack]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(m.opInterrupted, stack)
|
||||
m.persistRestoreRecordLocked()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func (m *Manager) RestoreSharesScratch(ctx context.Context) error {
|
||||
return util.MsgError("err.backup.nincs_visszaallithato_megosztas_mentes", err)
|
||||
}
|
||||
if err := os.MkdirAll(scratch, 0o755); err != nil {
|
||||
return fmt.Errorf("restore dir: %w", err)
|
||||
return m.restoreDirError(scratch, err)
|
||||
}
|
||||
t := m.settings.GetOffboxTarget()
|
||||
base, env := m.offboxBaseArgs(t)
|
||||
|
||||
Reference in New Issue
Block a user