Files
restic-manager/internal/agent/runner/runner_test.go
T
steve d8dd21b5e0
CI / Build (windows/amd64) (pull_request) Successful in 18s
CI / Build (linux/amd64) (pull_request) Successful in 19s
CI / Lint (pull_request) Successful in 41s
CI / Build (linux/arm64) (pull_request) Successful in 18s
CI / Test (linux/amd64) (pull_request) Failing after 3m41s
test: write-then-rename script-bin helpers (avoid ETXTBSY under -race)
CI run #48 failed with:

  --- FAIL: TestRunInitShipsStartedAndFinished
      RunInit: ... fork/exec /tmp/.../restic: text file busy

setupScript and setupScriptBin used os.WriteFile to write a shell
script directly at the final path, then exec'd it. Under -race +
many t.Parallel tests, a fork-from-another-goroutine could inherit
the still-open writable fd from one of those WriteFile calls; the
kernel returns ETXTBSY when the freshly-execed binary still has a
writable fd anywhere on the system.

Fix: write to "<path>.tmp", then os.Rename into place. The rename
is a pure dirent op; by the time the final path exists, no process
has a writable fd on its inode and exec is safe. -race + -count=5
on both runner packages now passes consistently.
2026-05-04 10:19:15 +01:00

358 lines
11 KiB
Go

package runner
import (
"context"
"os"
"path/filepath"
"testing"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/restic"
)
// fakeSender collects sent envelopes for assertions.
type fakeSender struct{ envs []api.Envelope }
func (s *fakeSender) Send(e api.Envelope) error {
s.envs = append(s.envs, e)
return nil
}
// setupScript writes a shell script (without shebang) to a temp dir,
// names it "restic", makes it executable, and returns the path.
//
// Writes to "<path>.tmp" then renames into place. The rename is what
// makes this race-free: under -race + many t.Parallel tests, a
// fork-from-another-goroutine can inherit the writable fd from
// os.WriteFile before close completes, and exec'ing the file then
// returns ETXTBSY ("text file busy"). Once the rename lands, the
// final path is a fresh dirent pointing at an inode that has no
// writable fd open anywhere — exec is safe.
func setupScript(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
final := filepath.Join(dir, "restic")
tmp := final + ".tmp"
if err := os.WriteFile(tmp, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatalf("setupScript: write tmp: %v", err)
}
if err := os.Rename(tmp, final); err != nil {
t.Fatalf("setupScript: rename: %v", err)
}
return final
}
// firstEnvOfType returns the first envelope with the given type, or
// fails the test if none is found.
func firstEnvOfType(t *testing.T, envs []api.Envelope, mt api.MessageType) api.Envelope {
t.Helper()
for _, e := range envs {
if e.Type == mt {
return e
}
}
t.Fatalf("no envelope of type %q found in %d envelopes", mt, len(envs))
return api.Envelope{}
}
// envelopeOrder returns the message types of all sent envelopes.
func envelopeOrder(envs []api.Envelope) []api.MessageType {
out := make([]api.MessageType, len(envs))
for i, e := range envs {
out[i] = e.Type
}
return out
}
// TestRunPruneShipsExpectedEnvelopes drives RunPrune with a fake
// binary that prints "prune" on stdout (for the log.stream envelope)
// and emits valid stats JSON so reportStats can populate size fields.
// Expected sequence: job.started → log.stream → repo.stats → job.finished.
func TestRunPruneShipsExpectedEnvelopes(t *testing.T) {
t.Parallel()
// The fake "restic" handles both "prune" and "stats --json" calls.
statsJSON := `{"total_size":1000,"total_uncompressed_size":2000,"snapshots_count":3,"total_file_count":10}`
bin := setupScript(t, `
case "$1" in
prune) echo "prune" ;;
stats) echo '`+statsJSON+`' ;;
*) echo "unknown: $*" ;;
esac
`)
tx := &fakeSender{}
r := New(Config{ResticBin: bin}, tx, 0)
if err := r.RunPrune(context.Background(), "job-1"); err != nil {
t.Fatalf("RunPrune: %v", err)
}
order := envelopeOrder(tx.envs)
// Confirm landmark envelope types appear in the required order.
wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished}
positions := map[api.MessageType]int{}
for i, mt := range order {
if _, seen := positions[mt]; !seen {
positions[mt] = i
}
}
for i := 0; i < len(wantTypes)-1; i++ {
a, b := wantTypes[i], wantTypes[i+1]
pa, aOK := positions[a]
pb, bOK := positions[b]
if !aOK {
t.Errorf("envelope type %q not found in output %v", a, order)
continue
}
if !bOK {
t.Errorf("envelope type %q not found in output %v", b, order)
continue
}
if pa >= pb {
t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order)
}
}
// The repo.stats payload must have LastPruneAt set.
statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats)
var statsPayload api.RepoStatsPayload
if err := statsEnv.UnmarshalPayload(&statsPayload); err != nil {
t.Fatalf("unmarshal repo.stats payload: %v", err)
}
if statsPayload.LastPruneAt == nil {
t.Error("expected LastPruneAt to be set in repo.stats after prune")
}
// The job.finished payload must indicate success.
finEnv := firstEnvOfType(t, tx.envs, api.MsgJobFinished)
var finPayload api.JobFinishedPayload
if err := finEnv.UnmarshalPayload(&finPayload); err != nil {
t.Fatalf("unmarshal job.finished payload: %v", err)
}
if finPayload.Status != api.JobSucceeded {
t.Errorf("expected job.finished status=%q, got %q", api.JobSucceeded, finPayload.Status)
}
}
// TestRunCheckShipsCheckStatus verifies that a check run which emits
// a stale-lock line on stderr (exit 0) reports LastCheckStatus="ok"
// and LockPresent=true.
func TestRunCheckShipsCheckStatus(t *testing.T) {
t.Parallel()
statsJSON := `{"total_size":500,"total_uncompressed_size":600,"snapshots_count":1,"total_file_count":5}`
bin := setupScript(t, `
case "$1" in
check) echo "Found stale lock" >&2; exit 0 ;;
stats) echo '`+statsJSON+`' ;;
*) exit 0 ;;
esac
`)
tx := &fakeSender{}
r := New(Config{ResticBin: bin}, tx, 0)
if err := r.RunCheck(context.Background(), "job-2", 0); err != nil {
t.Fatalf("RunCheck: %v", err)
}
// Assert envelope ordering: job.started → log.stream → repo.stats → job.finished.
order := envelopeOrder(tx.envs)
wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished}
positions := map[api.MessageType]int{}
for i, mt := range order {
if _, seen := positions[mt]; !seen {
positions[mt] = i
}
}
for i := 0; i < len(wantTypes)-1; i++ {
a, b := wantTypes[i], wantTypes[i+1]
pa, aOK := positions[a]
pb, bOK := positions[b]
if !aOK {
t.Errorf("envelope type %q not found in output %v", a, order)
continue
}
if !bOK {
t.Errorf("envelope type %q not found in output %v", b, order)
continue
}
if pa >= pb {
t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order)
}
}
statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats)
var p api.RepoStatsPayload
if err := statsEnv.UnmarshalPayload(&p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if p.LastCheckStatus != "ok" {
t.Errorf("LastCheckStatus: got %q, want %q", p.LastCheckStatus, "ok")
}
if p.LockPresent == nil || !*p.LockPresent {
t.Errorf("expected LockPresent=true, got %v", p.LockPresent)
}
if p.LastCheckAt == nil {
t.Error("expected LastCheckAt to be set")
}
}
// TestRunCheckErrorsFoundShipsErrorsStatus verifies that a check run
// that exits 1 (errors found) reports LastCheckStatus="errors_found".
func TestRunCheckErrorsFoundShipsErrorsStatus(t *testing.T) {
t.Parallel()
statsJSON := `{"total_size":500,"total_uncompressed_size":600,"snapshots_count":1,"total_file_count":5}`
bin := setupScript(t, `
case "$1" in
check) exit 1 ;;
stats) echo '`+statsJSON+`' ;;
*) exit 0 ;;
esac
`)
tx := &fakeSender{}
r := New(Config{ResticBin: bin}, tx, 0)
// RunCheck returns nil for exit 1 (errors_found is not a wrapper failure).
if err := r.RunCheck(context.Background(), "job-3", 0); err != nil {
t.Fatalf("RunCheck: %v", err)
}
// Assert envelope ordering: job.started → repo.stats → job.finished.
// (No log.stream expected because the fake script produces no
// output before exit 1 — a real restic check would emit log lines
// before exiting non-zero.)
order := envelopeOrder(tx.envs)
wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgRepoStats, api.MsgJobFinished}
positions := map[api.MessageType]int{}
for i, mt := range order {
if _, seen := positions[mt]; !seen {
positions[mt] = i
}
}
for i := 0; i < len(wantTypes)-1; i++ {
a, b := wantTypes[i], wantTypes[i+1]
pa, aOK := positions[a]
pb, bOK := positions[b]
if !aOK {
t.Errorf("envelope type %q not found in output %v", a, order)
continue
}
if !bOK {
t.Errorf("envelope type %q not found in output %v", b, order)
continue
}
if pa >= pb {
t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order)
}
}
statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats)
var p api.RepoStatsPayload
if err := statsEnv.UnmarshalPayload(&p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if p.LastCheckStatus != "errors_found" {
t.Errorf("LastCheckStatus: got %q, want %q", p.LastCheckStatus, "errors_found")
}
}
// TestRunUnlockClearsLock verifies that a successful unlock ships a
// repo.stats envelope with LockPresent=false.
func TestRunUnlockClearsLock(t *testing.T) {
t.Parallel()
statsJSON := `{"total_size":100,"total_uncompressed_size":150,"snapshots_count":2,"total_file_count":8}`
bin := setupScript(t, `
case "$1" in
unlock) echo "removed 1 locks" ;;
stats) echo '`+statsJSON+`' ;;
*) exit 0 ;;
esac
`)
tx := &fakeSender{}
r := New(Config{ResticBin: bin}, tx, 0)
if err := r.RunUnlock(context.Background(), "job-4"); err != nil {
t.Fatalf("RunUnlock: %v", err)
}
// Assert envelope ordering: job.started → log.stream → repo.stats → job.finished.
order := envelopeOrder(tx.envs)
wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished}
positions := map[api.MessageType]int{}
for i, mt := range order {
if _, seen := positions[mt]; !seen {
positions[mt] = i
}
}
for i := 0; i < len(wantTypes)-1; i++ {
a, b := wantTypes[i], wantTypes[i+1]
pa, aOK := positions[a]
pb, bOK := positions[b]
if !aOK {
t.Errorf("envelope type %q not found in output %v", a, order)
continue
}
if !bOK {
t.Errorf("envelope type %q not found in output %v", b, order)
continue
}
if pa >= pb {
t.Errorf("expected %q before %q but positions are %d >= %d (order: %v)", a, b, pa, pb, order)
}
}
statsEnv := firstEnvOfType(t, tx.envs, api.MsgRepoStats)
var p api.RepoStatsPayload
if err := statsEnv.UnmarshalPayload(&p); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if p.LockPresent == nil {
t.Fatal("expected LockPresent to be set (non-nil)")
}
if *p.LockPresent {
t.Errorf("expected LockPresent=false after unlock, got true")
}
}
// TestRunInitShipsStartedAndFinished confirms the refactored RunInit
// still produces job.started and job.finished envelopes.
func TestRunInitShipsStartedAndFinished(t *testing.T) {
t.Parallel()
bin := setupScript(t, `echo "initialized repository"`)
tx := &fakeSender{}
r := New(Config{ResticBin: bin}, tx, 0)
if err := r.RunInit(context.Background(), "job-init"); err != nil {
t.Fatalf("RunInit: %v", err)
}
_ = firstEnvOfType(t, tx.envs, api.MsgJobStarted)
_ = firstEnvOfType(t, tx.envs, api.MsgJobFinished)
}
// TestRunForgetShipsStartedAndFinished confirms the refactored
// RunForget still produces job.started and job.finished envelopes.
func TestRunForgetShipsStartedAndFinished(t *testing.T) {
t.Parallel()
// Script handles both "forget --json ..." and "snapshots --json" calls.
bin := setupScript(t, `
case "$1" in
forget) echo "[]" ;;
snapshots) echo "[]" ;;
*) exit 0 ;;
esac
`)
tx := &fakeSender{}
r := New(Config{ResticBin: bin}, tx, 0)
keepLast := 1
groups := []restic.ForgetGroup{{
Tag: "documents",
Policy: restic.ForgetPolicy{KeepLast: &keepLast},
}}
if err := r.RunForget(context.Background(), "job-forget", groups); err != nil {
t.Fatalf("RunForget: %v", err)
}
_ = firstEnvOfType(t, tx.envs, api.MsgJobStarted)
_ = firstEnvOfType(t, tx.envs, api.MsgJobFinished)
}