From 3c11a79252d50c65dd47f091a7ff6e899c9da177 Mon Sep 17 00:00:00 2001 From: Sam Hoffman Date: Sat, 7 Feb 2026 10:43:08 -0500 Subject: [PATCH] feat: error/success hooks --- Makefile | 2 +- internal/jobs/jobs.go | 115 ++++++++++++++++++++++++++++++++++--- internal/jobs/jobs_test.go | 36 ++++++++++++ internal/zfs/zfs.go | 25 ++++++++ 4 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 internal/jobs/jobs_test.go diff --git a/Makefile b/Makefile index 915f1e8..5fcd02b 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ LDFLAGS=-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" all: build-linux test: - go test ./... + go test -v ./... build-linux: GOOS=linux GOARCH=amd64 go build ${LDFLAGS} -o bin/thanks ./cmd/thanks diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 8c0d4c8..674dd17 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -7,12 +7,25 @@ import ( "context" "fmt" "log" + "os" + "os/exec" "strings" "time" "git.gentoo.party/sam/thanks/internal/zfs" ) +type BackupHookCommand struct { + Command string `yaml:"command"` + Args []string `yaml:"args"` + Env []string `yaml:"env"` +} + +type BackupHooks struct { + Completed BackupHookCommand `yaml:"completed"` // called upon successful completion of a backup job + Error BackupHookCommand `yaml:"error"` +} + type BackupJob struct { Source string `yaml:"source"` // the source dataset (e.g., zroot) TargetHost string `yaml:"targetHost"` // SSH-compatible host @@ -21,6 +34,7 @@ type BackupJob struct { MaxAge time.Duration `yaml:"maxAge"` // age at which to delete snapshot (if Keep is met) Recursive bool `yaml:"recursive"` // create recursive snapshots Prefix string `yaml:"prefix"` // name each snapshot with this prefix + Hooks BackupHooks `yaml:"hooks"` // external programs (libnotify, sendmail, etc) to call } // func (j *BackupJob) getBaseSnap() { @@ -71,7 +85,7 @@ func (j *BackupJob) Snapshot(ctx context.Context) (string, error) { return snap, err } -func (j *BackupJob) FullSend(ctx context.Context, snap string) { +func (j *BackupJob) FullSend(ctx context.Context, snap string) error { out, err := zfs.FullSend( ctx, snap, @@ -79,8 +93,9 @@ func (j *BackupJob) FullSend(ctx context.Context, snap string) { j.Target, ) if err != nil { - log.Fatalf("zfs-send: error: %s", out) + return fmt.Errorf("zfs-send: error: %s - %s", err.Error(), out) } + return nil } func (j *BackupJob) IncrementalSend(ctx context.Context, prevSnap *zfs.Snapshot, newSnap string) ([]byte, error) { @@ -122,10 +137,40 @@ func (j *BackupJob) Retain(ctx context.Context) error { return nil } -func (j *BackupJob) Do(ctx context.Context) { +// func (j *BackupJob) findCommonAnscestor(ctx context.Context) { +// localSnapshots, err := j.listSnapshots(ctx) +// } + +type HookErr struct { + message string +} + +func (e *HookErr) Error() string { + return fmt.Sprintf("error while running hook: %s", e.message) +} + +func (j *BackupJob) Do(ctx context.Context) error { + err := j.do(ctx) + if err != nil { + log.Print("job encountered error, running error hook\n") + err = j.runHookError(ctx, err) + if err != nil { + log.Printf("error running hook! %s", err.Error()) + } + return &HookErr{message: err.Error()} + + } else { + err = j.runHookCompleted(ctx) + return &HookErr{message: err.Error()} + } + + return err +} + +func (j *BackupJob) do(ctx context.Context) error { currentSnaps, err := j.listSnapshots(ctx) if err != nil { - log.Fatal("failed to list existing snapshots") + return fmt.Errorf("failed to list existing snapshots: %s", err.Error()) } var prev *zfs.Snapshot @@ -135,14 +180,70 @@ func (j *BackupJob) Do(ctx context.Context) { newSnapshot, err := j.Snapshot(ctx) if err != nil { - log.Fatalf("error creating new snapshot: %s", err.Error()) + return fmt.Errorf("error creating new snapshot: %s", err.Error()) } + var sendErr error if prev == nil { // Full Send - j.FullSend(ctx, newSnapshot) + sendErr = j.FullSend(ctx, newSnapshot) } else { // Inc send - j.IncrementalSend(ctx, prev, newSnapshot) + _, sendErr = j.IncrementalSend(ctx, prev, newSnapshot) } + + if sendErr != nil { + return fmt.Errorf("error sending backup: %s", sendErr.Error()) + } + + return nil +} + +func (j *BackupJob) runHookCompleted(ctx context.Context) error { + cmdDef := j.Hooks.Completed + if cmdDef.Command == "" { + return nil + } + + timeoutCtx, cancel := context.WithTimeout( + ctx, + time.Second*3, + ) + cmd := exec.CommandContext(timeoutCtx, cmdDef.Command, cmdDef.Args...) + defer cancel() + cmd.Env = []string{ + fmt.Sprintf("THANKS_JOB_SOURCE=%s", j.Source), + fmt.Sprintf("THANKS_JOB_TARGET=%s", j.Target), + } + return cmd.Run() +} + +func (j *BackupJob) runHookError(ctx context.Context, err error) error { + cmdDef := j.Hooks.Error + if cmdDef.Command == "" { + return nil + } + + timeoutCtx, cancel := context.WithTimeout( + ctx, + time.Second*3, + ) + cmd := exec.CommandContext(timeoutCtx, cmdDef.Command, cmdDef.Args...) + defer cancel() + cmd.Env = append( + []string{ + fmt.Sprintf("THANKS_JOB_SOURCE=%s", j.Source), + fmt.Sprintf("THANKS_JOB_TARGET=%s", j.Target), + fmt.Sprintf("THANKS_JOB_ERROR=%s", err.Error()), + fmt.Sprintf("DISPLAY=%s", os.Getenv("DISPLAY")), + }, + cmdDef.Env..., + ) + + out, runErr := cmd.CombinedOutput() + if runErr != nil { + log.Printf("%s", out) + } + + return runErr } diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go new file mode 100644 index 0000000..7435231 --- /dev/null +++ b/internal/jobs/jobs_test.go @@ -0,0 +1,36 @@ +package jobs_test + +import ( + "context" + "testing" + "time" + + "git.gentoo.party/sam/thanks/internal/jobs" + "github.com/stretchr/testify/assert" +) + +func Test_JobHookError(t *testing.T) { + job := jobs.BackupJob{ + Target: "znothing/nothing", + TargetHost: "devnull", + Source: "zempty/empty", + Keep: 1, + MaxAge: 1 * time.Second, + Recursive: false, + Hooks: jobs.BackupHooks{ + Error: jobs.BackupHookCommand{ + Command: "/usr/bin/notify-send", + Args: []string{"--app-name=thanks", "--urgency=CRITICAL", "Backup Failure", "Backup failed"}, + Env: []string{ + "XDG_RUNTIME_DIR=/run/user/1000/", + }, + }, + }, + } + + var hookErr *jobs.HookErr + + err := job.Do(context.Background()) + assert.NotNil(t, err, "expected job to produce an error") + assert.NotErrorAsf(t, err, &hookErr, "expected a non-hook error, got %w", err) +} diff --git a/internal/zfs/zfs.go b/internal/zfs/zfs.go index 3dfa0c9..cc8c076 100644 --- a/internal/zfs/zfs.go +++ b/internal/zfs/zfs.go @@ -78,6 +78,31 @@ func ParseSnapshots(reader io.Reader) ([]Snapshot, error) { return snaps, nil } +type Host struct { + SSH string + ZFSPath string +} + +func (h *Host) CommandContext(ctx context.Context, name string, arg ...string) *exec.Cmd { + var args []string + if h.SSH != "" { + name = "ssh" + args = append([]string{"ssh", h.SSH}, arg...) + } else { + args = arg + } + + cmd := exec.CommandContext( + ctx, + name, + args..., + ) + + return cmd +} + +// h.Comman + func Snapshots(ctx context.Context, target string) ([]Snapshot, error) { cmd := exec.CommandContext( ctx,