e4031d26fa
CI / Lint (pull_request) Successful in 35s
CI / Build (linux/amd64) (pull_request) Successful in 20s
CI / Build (windows/amd64) (pull_request) Successful in 1m18s
CI / Build (linux/arm64) (pull_request) Successful in 46s
CI / Test (linux/amd64) (pull_request) Failing after 2m46s
1. Agent-side MkdirAll on the new-dir restore target. Restic creates
missing leaves but won't traverse multiple missing levels, and
under the systemd sandbox writes outside ReadWritePaths fail
anyway. Calling os.MkdirAll(target, 0700) before invoking restic
means the operator never has to pre-create the per-job subdir,
and a path the sandbox rejects surfaces as a clean
'restic restore: prepare target ...: read-only file system' error
in the job log instead of a cryptic restic-side stat failure.
2. tasks.md Phase 3 — Restore section refreshed:
- P3-X4 added (job log download dropdown — txt + ndjson)
- P3-X5 added (UK lint locale switch + 73-correction sweep)
- P3-X6 added (SIZE/FILES tooltip when host's restic < 0.17)
- P3-03 entry expanded to cover version-gated --no-ownership,
editable target, $HOME expansion, agent-side MkdirAll
- As-shipped sweep summary mentions custom-target restore +
download dropdown + tooltip in addition to the original walk
Test: TestRunRestoreNewDirAutoCreatesTarget seeds a multi-level
target the operator hasn't created and confirms RunRestore mkdir's
the chain before invoking restic.
267 lines
8.2 KiB
Go
267 lines
8.2 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
|
)
|
|
|
|
// TestRunRestoreShipsExpectedEnvelopes: a fake restic emits a couple
|
|
// of restore status lines and a summary; the runner translates them
|
|
// into job.progress envelopes and finishes the job successfully.
|
|
func TestRunRestoreShipsExpectedEnvelopes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bin := setupScript(t, `
|
|
case "$1" in
|
|
restore)
|
|
echo '{"message_type":"status","seconds_elapsed":1,"percent_done":0.5,"total_files":10,"files_restored":5,"total_bytes":1000,"bytes_restored":500}'
|
|
echo '{"message_type":"status","seconds_elapsed":2,"percent_done":1.0,"total_files":10,"files_restored":10,"total_bytes":1000,"bytes_restored":1000}'
|
|
echo '{"message_type":"summary","seconds_elapsed":2,"total_files":10,"files_restored":10,"total_bytes":1000,"bytes_restored":1000}'
|
|
;;
|
|
*)
|
|
echo "unknown: $*" ;;
|
|
esac
|
|
`)
|
|
|
|
tx := &fakeSender{}
|
|
r := New(Config{ResticBin: bin}, tx, 0)
|
|
|
|
if err := r.RunRestore(context.Background(), "job-r1", "f3a7b2c1",
|
|
[]string{"/etc/nginx/sites-available/alfa.conf"},
|
|
false, "/tmp/restore-out"); err != nil {
|
|
t.Fatalf("RunRestore: %v", err)
|
|
}
|
|
|
|
// Confirm landmarks: started → progress → finished.
|
|
order := envelopeOrder(tx.envs)
|
|
wants := []api.MessageType{api.MsgJobStarted, api.MsgJobProgress, 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(wants)-1; i++ {
|
|
a, b := wants[i], wants[i+1]
|
|
pa, aOK := positions[a]
|
|
pb, bOK := positions[b]
|
|
if !aOK {
|
|
t.Fatalf("envelope %q not found in %v", a, order)
|
|
}
|
|
if !bOK {
|
|
t.Fatalf("envelope %q not found in %v", b, order)
|
|
}
|
|
if pa >= pb {
|
|
t.Fatalf("expected %q before %q (positions %d, %d)", a, b, pa, pb)
|
|
}
|
|
}
|
|
|
|
// Started carries the right kind.
|
|
startEnv := firstEnvOfType(t, tx.envs, api.MsgJobStarted)
|
|
var startP api.JobStartedPayload
|
|
if err := startEnv.UnmarshalPayload(&startP); err != nil {
|
|
t.Fatalf("unmarshal started: %v", err)
|
|
}
|
|
if startP.Kind != api.JobRestore {
|
|
t.Fatalf("kind: got %q want %q", startP.Kind, api.JobRestore)
|
|
}
|
|
|
|
// Finished is succeeded.
|
|
finEnv := firstEnvOfType(t, tx.envs, api.MsgJobFinished)
|
|
var finP api.JobFinishedPayload
|
|
if err := finEnv.UnmarshalPayload(&finP); err != nil {
|
|
t.Fatalf("unmarshal finished: %v", err)
|
|
}
|
|
if finP.Status != api.JobSucceeded {
|
|
t.Fatalf("status: got %q want %q", finP.Status, api.JobSucceeded)
|
|
}
|
|
// Progress envelope reflects the last status line: 100% with 10 files.
|
|
progEnv := firstEnvOfType(t, tx.envs, api.MsgJobProgress)
|
|
var progP api.JobProgressPayload
|
|
if err := progEnv.UnmarshalPayload(&progP); err != nil {
|
|
t.Fatalf("unmarshal progress: %v", err)
|
|
}
|
|
// First progress will be from line 1 (50%) since we send first status
|
|
// immediately. Verify we at least see a sensible value.
|
|
if progP.PercentDone <= 0 {
|
|
t.Fatalf("expected non-zero progress, got %v", progP.PercentDone)
|
|
}
|
|
if progP.FilesDone <= 0 || progP.TotalFiles <= 0 {
|
|
t.Fatalf("expected file counters set, got %+v", progP)
|
|
}
|
|
}
|
|
|
|
// TestRunRestoreInPlaceArgvHasNoNoOwnership: indirectly verifies that
|
|
// in-place mode doesn't pass --no-ownership. We can't see the actual
|
|
// argv without a custom test harness, so we use a fake restic that
|
|
// echoes its args and check the captured log.stream.
|
|
func TestRunRestoreInPlaceArgvHasNoNoOwnership(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bin := setupScript(t, `
|
|
case "$1" in
|
|
restore)
|
|
# Print all args on stderr so they're forwarded as log.stream.
|
|
echo "argv: $*" 1>&2
|
|
echo '{"message_type":"summary","seconds_elapsed":0,"total_files":0,"files_restored":0,"total_bytes":0,"bytes_restored":0}'
|
|
;;
|
|
esac
|
|
`)
|
|
|
|
tx := &fakeSender{}
|
|
r := New(Config{ResticBin: bin}, tx, 0)
|
|
if err := r.RunRestore(context.Background(), "job-r2", "abc",
|
|
nil, true, ""); err != nil {
|
|
t.Fatalf("RunRestore: %v", err)
|
|
}
|
|
|
|
// Reconstruct the argv from the captured stderr log line.
|
|
var argv string
|
|
for _, e := range tx.envs {
|
|
if e.Type == api.MsgLogStream {
|
|
var p api.LogStreamLine
|
|
_ = e.UnmarshalPayload(&p)
|
|
if p.Stream == api.LogStderr && strings.HasPrefix(p.Payload, "argv:") {
|
|
argv = p.Payload
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if argv == "" {
|
|
t.Fatal("never captured argv echo from fake restic")
|
|
}
|
|
if strings.Contains(argv, "--no-ownership") {
|
|
t.Errorf("in-place restore should NOT pass --no-ownership; got argv=%q", argv)
|
|
}
|
|
if !strings.Contains(argv, "--target /") {
|
|
t.Errorf("in-place restore should pass --target /; got argv=%q", argv)
|
|
}
|
|
}
|
|
|
|
// TestRunRestoreNewDirArgvShape: non-in-place restore passes --target
|
|
// to the operator-chosen new directory and includes the path filters.
|
|
// We deliberately do NOT pass --no-ownership (added in restic 0.17;
|
|
// older versions error out — the comment in restore.go explains why).
|
|
func TestRunRestoreNewDirArgvShape(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bin := setupScript(t, `
|
|
case "$1" in
|
|
restore)
|
|
echo "argv: $*" 1>&2
|
|
echo '{"message_type":"summary","seconds_elapsed":0,"total_files":0,"files_restored":0,"total_bytes":0,"bytes_restored":0}'
|
|
;;
|
|
esac
|
|
`)
|
|
tx := &fakeSender{}
|
|
r := New(Config{ResticBin: bin}, tx, 0)
|
|
if err := r.RunRestore(context.Background(), "job-r3", "abc",
|
|
[]string{"/etc/foo"}, false, "/tmp/restore-out"); err != nil {
|
|
t.Fatalf("RunRestore: %v", err)
|
|
}
|
|
|
|
var argv string
|
|
for _, e := range tx.envs {
|
|
if e.Type == api.MsgLogStream {
|
|
var p api.LogStreamLine
|
|
_ = e.UnmarshalPayload(&p)
|
|
if p.Stream == api.LogStderr && strings.HasPrefix(p.Payload, "argv:") {
|
|
argv = p.Payload
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if argv == "" {
|
|
t.Fatal("no argv echo")
|
|
}
|
|
if strings.Contains(argv, "--no-ownership") {
|
|
t.Errorf("restic 0.16 doesn't accept --no-ownership; got argv=%q", argv)
|
|
}
|
|
if !strings.Contains(argv, "--target /tmp/restore-out") {
|
|
t.Errorf("expected --target /tmp/restore-out; got argv=%q", argv)
|
|
}
|
|
if !strings.Contains(argv, "--include /etc/foo") {
|
|
t.Errorf("expected --include /etc/foo; got argv=%q", argv)
|
|
}
|
|
}
|
|
|
|
// TestRunRestoreNewDirAutoCreatesTarget: a new-directory restore
|
|
// should mkdir the requested target chain before invoking restic, so
|
|
// operators don't have to pre-create the per-job subdir.
|
|
func TestRunRestoreNewDirAutoCreatesTarget(t *testing.T) {
|
|
t.Parallel()
|
|
bin := setupScript(t, `
|
|
case "$1" in
|
|
restore)
|
|
echo '{"message_type":"summary","seconds_elapsed":0,"total_files":0,"files_restored":0,"total_bytes":0,"bytes_restored":0}'
|
|
;;
|
|
esac
|
|
`)
|
|
tx := &fakeSender{}
|
|
r := New(Config{ResticBin: bin}, tx, 0)
|
|
|
|
// Multi-level path the operator hasn't created yet.
|
|
target := filepath.Join(t.TempDir(), "deep", "deeper", "deepest")
|
|
if err := r.RunRestore(context.Background(), "job-rmkdir", "abc",
|
|
[]string{"/etc/foo"}, false, target); err != nil {
|
|
t.Fatalf("RunRestore: %v", err)
|
|
}
|
|
|
|
if st, err := os.Stat(target); err != nil {
|
|
t.Fatalf("expected target dir to exist: %v", err)
|
|
} else if !st.IsDir() {
|
|
t.Fatalf("expected directory, got %v", st.Mode())
|
|
}
|
|
}
|
|
|
|
// TestRunDiffShipsLogLines: diff output is forwarded as log.stream.
|
|
func TestRunDiffShipsLogLines(t *testing.T) {
|
|
t.Parallel()
|
|
bin := setupScript(t, `
|
|
case "$1" in
|
|
diff)
|
|
echo '{"message_type":"change","path":"/etc/nginx/nginx.conf","modifier":"M"}'
|
|
echo '{"message_type":"statistics","added":{"files":0,"dirs":0}}'
|
|
;;
|
|
esac
|
|
`)
|
|
tx := &fakeSender{}
|
|
r := New(Config{ResticBin: bin}, tx, 0)
|
|
if err := r.RunDiff(context.Background(), "job-d1", "snap-a", "snap-b"); err != nil {
|
|
t.Fatalf("RunDiff: %v", err)
|
|
}
|
|
|
|
startEnv := firstEnvOfType(t, tx.envs, api.MsgJobStarted)
|
|
var startP api.JobStartedPayload
|
|
_ = startEnv.UnmarshalPayload(&startP)
|
|
if startP.Kind != api.JobDiff {
|
|
t.Fatalf("kind: got %q want %q", startP.Kind, api.JobDiff)
|
|
}
|
|
finEnv := firstEnvOfType(t, tx.envs, api.MsgJobFinished)
|
|
var finP api.JobFinishedPayload
|
|
_ = finEnv.UnmarshalPayload(&finP)
|
|
if finP.Status != api.JobSucceeded {
|
|
t.Fatalf("status: %q", finP.Status)
|
|
}
|
|
// At least one log line should carry the change payload.
|
|
var sawChange bool
|
|
for _, e := range tx.envs {
|
|
if e.Type != api.MsgLogStream {
|
|
continue
|
|
}
|
|
var p api.LogStreamLine
|
|
_ = e.UnmarshalPayload(&p)
|
|
if strings.Contains(p.Payload, `"message_type":"change"`) {
|
|
sawChange = true
|
|
}
|
|
}
|
|
if !sawChange {
|
|
t.Fatal("never saw a change log line in diff output")
|
|
}
|
|
}
|