agent+server: P2R-11 pre/post hook execution for backup jobs
Agent: new runner.BackupHooks struct + runHook helper invoked via /bin/sh -c (cmd.exe /C on Windows). pre_hook non-zero exit aborts the backup; post_hook always runs with RM_JOB_STATUS=succeeded|failed in env. Output streamed as 'hook(<phase>): …' log.stream lines. Hooks only run for kind=backup (other kinds skip both phases). Server: resolveBackupHooks resolves group → host default → empty, decrypts via crypto.AEAD with per-slot ad bytes, plumbs plaintext into CommandRunPayload for both schedule.fire and per-group Run-now dispatch sites. Decrypt failures degrade silently to no hook so a malformed blob can't poison every backup.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// hooks_test.go — pre/post backup hook semantics (P2R-11).
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
)
|
||||
|
||||
// TestPreHookFailureAbortsBackup: pre_hook exits 1 → restic never
|
||||
// runs, job is recorded failed with the hook's error.
|
||||
func TestPreHookFailureAbortsBackup(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Restic script that records every invocation. If restic was
|
||||
// called we'll see "restic-was-here" in the captured log.
|
||||
bin := setupScript(t, `echo "restic-was-here"`)
|
||||
tx := &fakeSender{}
|
||||
r := New(Config{ResticBin: bin}, tx, 0)
|
||||
|
||||
err := r.RunBackup(context.Background(), "job-pre",
|
||||
[]string{"/etc"}, nil, []string{"tag"},
|
||||
BackupHooks{Pre: "exit 1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected RunBackup to return an error from failed pre_hook")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pre_hook failed") {
|
||||
t.Fatalf("error message: %q (want 'pre_hook failed')", err)
|
||||
}
|
||||
// job.finished arrived with status=failed.
|
||||
finEnv := firstEnvOfType(t, tx.envs, api.MsgJobFinished)
|
||||
var fin api.JobFinishedPayload
|
||||
_ = finEnv.UnmarshalPayload(&fin)
|
||||
if fin.Status != api.JobFailed {
|
||||
t.Fatalf("status: %q, want failed", fin.Status)
|
||||
}
|
||||
// restic must NOT have run.
|
||||
for _, env := range tx.envs {
|
||||
if env.Type != api.MsgLogStream {
|
||||
continue
|
||||
}
|
||||
var l api.LogStreamLine
|
||||
_ = env.UnmarshalPayload(&l)
|
||||
if strings.Contains(l.Payload, "restic-was-here") {
|
||||
t.Fatal("restic was invoked despite pre_hook failure")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostHookRunsAfterBackup: post_hook fires after a successful
|
||||
// backup and receives RM_JOB_STATUS=succeeded in the env.
|
||||
func TestPostHookRunsAfterBackup(t *testing.T) {
|
||||
t.Parallel()
|
||||
bin := setupScript(t, `
|
||||
case "$1" in
|
||||
backup) echo '{"message_type":"summary","snapshot_id":"abc"}' ;;
|
||||
snapshots) echo '[]' ;;
|
||||
stats) echo '{"total_size":0,"total_uncompressed_size":0,"snapshots_count":0,"total_file_count":0,"total_blob_count":0}' ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
`)
|
||||
tx := &fakeSender{}
|
||||
r := New(Config{ResticBin: bin}, tx, 0)
|
||||
|
||||
post := `echo "post-status=$RM_JOB_STATUS phase=$RM_HOOK_PHASE"`
|
||||
if err := r.RunBackup(context.Background(), "job-post",
|
||||
[]string{"/etc"}, nil, nil, BackupHooks{Post: post}); err != nil {
|
||||
t.Fatalf("RunBackup: %v", err)
|
||||
}
|
||||
|
||||
// Walk log.stream envelopes; one of them should be the post-hook
|
||||
// line with the expected status.
|
||||
var found bool
|
||||
for _, env := range tx.envs {
|
||||
if env.Type != api.MsgLogStream {
|
||||
continue
|
||||
}
|
||||
var l api.LogStreamLine
|
||||
_ = env.UnmarshalPayload(&l)
|
||||
if strings.Contains(l.Payload, "post-status=succeeded") &&
|
||||
strings.Contains(l.Payload, "phase=post") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("post_hook output not found in log.stream envelopes")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user