From f3eaf511be645af4ecd4a36af292aa3c99567430 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 22:21:48 +0100 Subject: [PATCH] restic: RunCheck with subset% + lock-state sniffing Add CheckResult (LockPresent, ErrorsFound) and RunCheck. subsetPct>0 passes --read-data-subset N% to limit data reads. Stderr is sniffed for "Found stale lock"/"locked" to set LockPresent; a non-zero exit from restic is absorbed as ErrorsFound=true rather than an error so the caller can always persist last_check_status. Tests cover lock detection, exit-1 absorption, and subset-arg plumbing. --- internal/restic/runner.go | 53 ++++++++++++++++++++++++++++++++++ internal/restic/runner_test.go | 47 +++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/internal/restic/runner.go b/internal/restic/runner.go index 0b4b671..ab79ac2 100644 --- a/internal/restic/runner.go +++ b/internal/restic/runner.go @@ -293,6 +293,59 @@ func runWithPump(cmd *exec.Cmd, handle LineHandler) error { return nil } +// CheckResult summarizes a `restic check` invocation. LockPresent is +// true if the stderr stream contained a stale-lock signal (caller is +// expected to surface this in the UI so the operator can run unlock). +// ErrorsFound is true if check exited with a non-zero status (errors +// detected in repo metadata). +type CheckResult struct { + LockPresent bool + ErrorsFound bool +} + +// RunCheck executes `restic check` with optional --read-data-subset. +// subsetPct of 0 omits the flag (full data check); >0 passes +// --read-data-subset N%. Returns a CheckResult summarizing what was +// sniffed from stderr; the result is set even if check itself +// returns an error (so the caller can persist last_check_status). +func (e Env) RunCheck(ctx context.Context, subsetPct int, handle LineHandler) (CheckResult, error) { + args := []string{"check"} + if subsetPct > 0 { + args = append(args, "--read-data-subset", fmt.Sprintf("%d%%", subsetPct)) + } + cmd := exec.CommandContext(ctx, e.Bin, args...) + cmd.Env = e.envSlice() + cmd.Dir = e.WorkDir + + var res CheckResult + sniff := func(stream, line string, ev any) { + if stream == "stderr" { + if strings.Contains(line, "Found stale lock") || strings.Contains(line, "locked") { + res.LockPresent = true + } + } + if handle != nil { + handle(stream, line, ev) + } + } + + err := runWithPump(cmd, sniff) + if err != nil { + // restic check exits non-zero when corruption is found; that's + // a CheckResult, not a wrapper failure. Treat ExitError as + // "errors found" but still return the result so the caller can + // persist last_check_status='errors_found'. Reserve the error + // return for actually-broken invocations (binary missing, etc). + var ee *exec.ExitError + if errors.As(err, &ee) { + res.ErrorsFound = true + return res, nil + } + return res, err + } + return res, nil +} + func pumpPlain(r io.Reader, stream string, handle LineHandler) error { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) diff --git a/internal/restic/runner_test.go b/internal/restic/runner_test.go index 46c3a05..ba91e43 100644 --- a/internal/restic/runner_test.go +++ b/internal/restic/runner_test.go @@ -34,7 +34,7 @@ func captureLines() (*[]string, LineHandler) { return &lines, h } -// --- B1: RunPrune --- +// --- B1: RunPrune + B2: RunCheck --- func TestRunPruneInvokesPrune(t *testing.T) { // Shell script that echoes its args; "prune" should appear in output. @@ -51,3 +51,48 @@ func TestRunPruneInvokesPrune(t *testing.T) { } t.Fatalf("expected 'prune' in captured output; got: %v", *lines) } + +// --- B2: RunCheck --- + +func TestRunCheckParsesLock(t *testing.T) { + bin := setupScriptBin(t, `echo "Found stale lock" >&2`) + env := Env{Bin: bin} + res, err := env.RunCheck(context.Background(), 0, nil) + if err != nil { + t.Fatalf("RunCheck returned unexpected error: %v", err) + } + if !res.LockPresent { + t.Fatal("expected LockPresent=true") + } + if res.ErrorsFound { + t.Fatal("expected ErrorsFound=false") + } +} + +func TestRunCheckErrorsFoundOnExit1(t *testing.T) { + bin := setupScriptBin(t, `exit 1`) + env := Env{Bin: bin} + res, err := env.RunCheck(context.Background(), 0, nil) + if err != nil { + t.Fatalf("RunCheck returned unexpected error (should have absorbed exit 1): %v", err) + } + if !res.ErrorsFound { + t.Fatal("expected ErrorsFound=true for exit 1") + } +} + +func TestRunCheckSubsetArg(t *testing.T) { + bin := setupScriptBin(t, `echo "$@"`) + env := Env{Bin: bin} + lines, h := captureLines() + if _, err := env.RunCheck(context.Background(), 25, h); err != nil { + t.Fatalf("RunCheck: %v", err) + } + want := "--read-data-subset 25%" + for _, l := range *lines { + if strings.Contains(l, want) { + return + } + } + t.Fatalf("expected %q in captured output; got: %v", want, *lines) +}