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

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:
2026-10-05 20:12:56 +02:00
parent 114ff2761a
commit 1453cfc69b
68 changed files with 3953 additions and 116 deletions
+3
View File
@@ -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
+29 -4
View File
@@ -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) {
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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)