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.
This commit is contained in:
2026-05-03 22:21:48 +01:00
parent 2caf7f1193
commit f3eaf511be
2 changed files with 99 additions and 1 deletions
+53
View File
@@ -293,6 +293,59 @@ func runWithPump(cmd *exec.Cmd, handle LineHandler) error {
return nil 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 { func pumpPlain(r io.Reader, stream string, handle LineHandler) error {
scanner := bufio.NewScanner(r) scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+46 -1
View File
@@ -34,7 +34,7 @@ func captureLines() (*[]string, LineHandler) {
return &lines, h return &lines, h
} }
// --- B1: RunPrune --- // --- B1: RunPrune + B2: RunCheck ---
func TestRunPruneInvokesPrune(t *testing.T) { func TestRunPruneInvokesPrune(t *testing.T) {
// Shell script that echoes its args; "prune" should appear in output. // 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) 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)
}