a781e95c94
Three small follow-ups from review:
1. Restore target is now operator-editable. Default value is the
literal '\$HOME/rm-restore/<job-id>/' (agent expands \$HOME at
run time using os.UserHomeDir(); also handles \${HOME} and ~/
prefixes). Operator can replace with any absolute path.
- ui_restore.go validates the input is either absolute or starts
with one of the recognised prefixes; other env-var refs (\$PATH
etc.) are deliberately rejected so operator paths can't pick up
arbitrary agent env values.
- host_restore.html replaces the read-only mono-text display with
a real <input>; help text spells out that \$HOME resolves
agent-side and <job-id> is substituted on dispatch.
- install.sh + the systemd unit prep /root/rm-restore so the
default works under the sandbox: ReadWritePaths gains a soft
'-/root/rm-restore' entry (the '-' makes the bind-mount soft-fail
if missing, but install.sh pre-creates it root-owned 0700).
2. --no-ownership flag now gated on restic version. The flag was
added in restic 0.17 and 0.16 rejects it. Previously dropped it
wholesale — that meant new-dir restores silently preserved
ownership against design intent on 0.17+. Now the agent threads
its detected restic version (sysinfo already collects it) through
runner.Config -> restic.Env, and RunRestore appends --no-ownership
only when AtLeastVersion(0, 17) returns true. 0.16 hosts still
restore with original uid/gid; help text in the wizard explicitly
notes this. The previous 'Original ownership is preserved' copy
was wrong for new-dir mode and is corrected.
3. golangci-lint misspell locale switched US -> UK and the codebase
swept (73 corrections, mostly behaviour/serialise/recognise/honour).
Wire-format ErrorCode 'unauthorized' -> 'unauthorised' is a tiny
contract change but the agent doesn't parse those codes today and
no external API consumers exist yet. Tests passed before + after.
Tests:
- internal/restic/version_test.go covers Env.AtLeastVersion across
edge cases (empty, exact match, patch above, minor below, non-
numeric) and expandHome on \$HOME / \${HOME} / ~/, plus
pass-through for absolute paths and refusal of other env vars.
- ui_restore_test updated: TargetDir now starts '\$HOME/rm-restore/'
with the job_id substituted into the placeholder.
Live verified on the smoke env: default target restored to
/root/rm-restore/<job-id>/ as the agent's expanded \$HOME (2 files,
14 bytes); custom override '/tmp/custom-restore/<job-id>/' restored
into the agent's PrivateTmp namespace (1 file, 6 bytes); both jobs
'succeeded', exit 0.
358 lines
11 KiB
Go
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 "initialised 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)
|
|
}
|