diff --git a/AGENTS.md b/AGENTS.md index f2ebd6bb882a35c677a71cac50617be411f4e252..08304e88f7232e0ed8f8204a81da9b39e10cd76d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,4 @@ -# AGENTS.md - Working with Keld - -## Project Overview +# AGENTS.md **Keld** is a friendly TOML-configured wrapper around [restic](https://restic.net/) (a backup tool). It provides: @@ -56,13 +54,15 @@ CLI overrides ### Special Config Keys -| Key | Purpose | -| ---------------- | ------------------------------------------------------------------ | -| `_arguments` | Positional args passed to restic (array or space-separated string) | -| `_workdir` | Directory to chdir before exec | -| `_command` | Restic subcommand (allows aliasing) | -| `*.environ` | Section suffix for environment variables | -| `FOO_COMMAND` | In `.environ`: executed via `sh -c`, stdout sets `FOO` | +| Key | Purpose | +| ------------- | ------------------------------------------------------------------ | +| `_arguments` | Positional args passed to restic (array or space-separated string) | +| `_workdir` | Directory to chdir before exec | +| `_command` | Restic subcommand (allows aliasing) | +| `_pre_hooks` | Backup-only shell commands run before restic backup | +| `_post_hooks` | Backup-only shell commands run after restic backup is attempted | +| `*.environ` | Section suffix for environment variables | +| `FOO_COMMAND` | In `.environ`: executed via `sh -c`, stdout sets `FOO` | ### Interpolation @@ -93,14 +93,11 @@ See `examples/keld/config.toml` for comprehensive examples. - Nested tables (sub-commands) are **not** merged across sections—only leaf keys - Multi-line strings become repeated flags (split on `\n`) -### syscall.Exec - -`restic.Run()` uses `syscall.Exec` which **replaces the current process**. -This means: +### Restic Process Supervision -- No Go code runs after successful exec -- No deferred functions execute -- Dry-run mode exists specifically to show what would run +`restic.Run()` supervises restic as a child process. This allows Keld to run +post-backup hooks after restic exits and preserve restic's numeric exit code for +callers. ### Test Isolation diff --git a/cmd/backup_hooks_feature_test.go b/cmd/backup_hooks_feature_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e1a8d11b0db5dd286156d29f4f4a3b9307d483e2 --- /dev/null +++ b/cmd/backup_hooks_feature_test.go @@ -0,0 +1,527 @@ +package cmd + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "sync" + "testing" + + "github.com/regen-network/gocuke" + "github.com/spf13/cobra" + + "git.secluded.site/keld/internal/config" +) + +func TestBackupHooksFeature(t *testing.T) { + gocuke.NewRunner(t, &backupHooksSuite{}). + Path("../features/backup_hooks.feature"). + NonParallel(). + Run() +} + +type backupHooksSuite struct { + gocuke.TestingT + + tmpDir string + configPath string + logPath string + preset string + resticCode int + resolved *config.ResolvedConfig + runErr error + runStdout string + runStderr string + oldPreset string + oldShowCmd bool + oldConfig string + oldDryRun string + oldConfigEnv string + oldExec string + oldTestLog string + oldTestExit string +} + +func (s *backupHooksSuite) Before() { + var err error + s.tmpDir, err = os.MkdirTemp("", "keld-backup-hooks-*") + if err != nil { + s.Fatalf("creating temp dir: %v", err) + } + s.configPath = filepath.Join(s.tmpDir, "config.toml") + s.logPath = filepath.Join(s.tmpDir, "events.log") + s.preset = "home" + s.resticCode = 0 + + s.oldPreset, s.oldShowCmd, s.oldConfig = flagPreset, flagShowCmd, flagConfigFile + s.oldDryRun = os.Getenv("KELD_DRYRUN") + s.oldConfigEnv = os.Getenv("KELD_CONFIG_FILE") + s.oldExec = os.Getenv("KELD_EXECUTABLE") + s.oldTestLog = os.Getenv("KELD_TEST_LOG") + s.oldTestExit = os.Getenv("KELD_TEST_RESTIC_EXIT") + + fakeRestic := filepath.Join(s.tmpDir, "restic") + script := `#!/bin/sh +printf 'restic %s\n' "$*" >> "$KELD_TEST_LOG" +exit "${KELD_TEST_RESTIC_EXIT:-0}" +` + if err := os.WriteFile(fakeRestic, []byte(script), 0o755); err != nil { + s.Fatalf("writing fake restic: %v", err) + } + mustSetenv(s, "KELD_EXECUTABLE", fakeRestic) + mustSetenv(s, "KELD_TEST_LOG", s.logPath) + mustSetenv(s, "KELD_TEST_RESTIC_EXIT", "0") +} + +func (s *backupHooksSuite) After() { + flagPreset, flagShowCmd, flagConfigFile = s.oldPreset, s.oldShowCmd, s.oldConfig + restoreEnv("KELD_DRYRUN", s.oldDryRun) + restoreEnv("KELD_CONFIG_FILE", s.oldConfigEnv) + restoreEnv("KELD_EXECUTABLE", s.oldExec) + restoreEnv("KELD_TEST_LOG", s.oldTestLog) + restoreEnv("KELD_TEST_RESTIC_EXIT", s.oldTestExit) + _ = os.RemoveAll(s.tmpDir) +} + +func (s *backupHooksSuite) TheSelectedPresetSuppliesBackupPaths() { + s.writeConfig("home", nil, nil) +} + +func (s *backupHooksSuite) BackupHooksAreConfiguredInMultipleMatchingSections() { + s.preset = "home@cloud" + s.writeRawConfig(` +[global.backup] +_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `] +_pre_hooks = [` + quote(s.logHook("pre global")) + `] +_post_hooks = [` + quote(s.logHook("post global")) + `] + +["@cloud".backup] +_pre_hooks = [` + quote(s.logHook("pre suffix")) + `] +_post_hooks = [` + quote(s.logHook("post suffix")) + `] + +["home@".backup] +_pre_hooks = [` + quote(s.logHook("pre prefix")) + `] +_post_hooks = [` + quote(s.logHook("post prefix")) + `] + +["home@cloud".backup] +_pre_hooks = [` + quote(s.logHook("pre full")) + `] +_post_hooks = [` + quote(s.logHook("post full")) + `] +`) +} + +func (s *backupHooksSuite) TheBackupConfigurationIsResolved() { + cfg, err := config.Resolve(s.preset, "backup", nil) + if err != nil { + s.Fatalf("resolving config: %v", err) + } + s.resolved = cfg +} + +func (s *backupHooksSuite) ThePrehooksAreOrderedFromLowestToHighestPrioritySection() { + want := []string{s.logHook("pre global"), s.logHook("pre suffix"), s.logHook("pre prefix"), s.logHook("pre full")} + if !reflect.DeepEqual(s.resolved.PreHooks, want) { + s.Fatalf("pre hook order mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want) + } +} + +func (s *backupHooksSuite) ThePrehooksComeFromTheMostspecificSectionThatDefinesPrehooks() { + want := []string{s.logHook("pre full")} + if !reflect.DeepEqual(s.resolved.PreHooks, want) { + s.Fatalf("pre hook override mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want) + } +} + +func (s *backupHooksSuite) ThePosthooksAreOrderedFromLowestToHighestPrioritySection() { + want := []string{s.logHook("post global"), s.logHook("post suffix"), s.logHook("post prefix"), s.logHook("post full")} + if !reflect.DeepEqual(s.resolved.PostHooks, want) { + s.Fatalf("post hook order mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want) + } +} + +func (s *backupHooksSuite) ThePosthooksComeFromTheMostspecificSectionThatDefinesPosthooks() { + want := []string{s.logHook("post full")} + if !reflect.DeepEqual(s.resolved.PostHooks, want) { + s.Fatalf("post hook override mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want) + } +} + +func (s *backupHooksSuite) TheHooksAreNotPassedToResticAsFlags() { + for _, f := range s.resolved.Flags { + if strings.Contains(f.Name, "hook") { + s.Fatalf("hook leaked into restic flags: %#v", f) + } + } +} + +func (s *backupHooksSuite) BackupHooksAreConfiguredInALessspecificMatchingSection() { + s.preset = "home" + s.writeRawConfig(` +[global.backup] +_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `] +_pre_hooks = [` + quote(s.logHook("pre inherited")) + `] +_post_hooks = [` + quote(s.logHook("post inherited")) + `] + +[home.backup] +tag = "specific" +`) +} + +func (s *backupHooksSuite) TheMostspecificMatchingSectionOmitsHookKeys() {} + +func (s *backupHooksSuite) TheInheritedPrehooksAreUsed() { + want := []string{s.logHook("pre inherited")} + if !reflect.DeepEqual(s.resolved.PreHooks, want) { + s.Fatalf("inherited pre hooks mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want) + } +} + +func (s *backupHooksSuite) TheInheritedPosthooksAreUsed() { + want := []string{s.logHook("post inherited")} + if !reflect.DeepEqual(s.resolved.PostHooks, want) { + s.Fatalf("inherited post hooks mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want) + } +} + +func (s *backupHooksSuite) TheMostspecificMatchingSectionClearsHookKeys() { + s.writeRawConfig(` +[global.backup] +_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `] +_pre_hooks = [` + quote(s.logHook("pre inherited")) + `] +_post_hooks = [` + quote(s.logHook("post inherited")) + `] + +[home.backup] +_pre_hooks = [] +_post_hooks = [] +`) +} + +func (s *backupHooksSuite) NoPrehooksAreConfigured() { + if len(s.resolved.PreHooks) != 0 { + s.Fatalf("expected no pre-hooks, got %#v", s.resolved.PreHooks) + } +} + +func (s *backupHooksSuite) NoPosthooksAreConfigured() { + if len(s.resolved.PostHooks) != 0 { + s.Fatalf("expected no post-hooks, got %#v", s.resolved.PostHooks) + } +} + +func (s *backupHooksSuite) TheBackupConfigurationIncludesPrehooks() { + s.writeConfig("home", []string{s.logHook("pre one"), s.logHook("pre two")}, nil) +} + +func (s *backupHooksSuite) KeldRunsTheBackup() { + flagPreset = s.preset + flagShowCmd = false + flagConfigFile = s.configPath + mustSetenv(s, "KELD_TEST_RESTIC_EXIT", strconv.Itoa(s.resticCode)) + + var err error + s.runStdout, s.runStderr, err = captureOutput(s, func() error { + return runCommand("backup", lookupSubcommandForHooks(s, "backup"), nil, nil, nil) + }) + s.runErr = err +} + +func (s *backupHooksSuite) EachPrehookRunsBeforeResticBackup() { + lines := s.logLines() + want := []string{"pre one", "pre two", "restic backup"} + for i, prefix := range want { + if len(lines) <= i || !strings.HasPrefix(lines[i], prefix) { + s.Fatalf("event %d mismatch: got log %#v, want prefix %q", i, lines, prefix) + } + } +} + +func (s *backupHooksSuite) ResticBackupIsAttempted() { + if !s.logContains("restic backup") { + s.Fatalf("restic backup was not attempted; log: %#v", s.logLines()) + } +} + +func (s *backupHooksSuite) TheBackupConfigurationIncludesAFailingPrehook() { + s.writeConfig("home", []string{s.logHook("pre fail") + "; exit 42"}, []string{s.logHook("post should-not-run")}) +} + +func (s *backupHooksSuite) ResticBackupIsNotAttempted() { + if s.logContains("restic backup") { + s.Fatalf("restic backup was attempted; log: %#v", s.logLines()) + } +} + +func (s *backupHooksSuite) PosthooksAreNotRun() { + if s.logContains("post") { + s.Fatalf("post-hook ran unexpectedly; log: %#v", s.logLines()) + } +} + +func (s *backupHooksSuite) KeldReportsThePrehookFailure() { + if s.runErr == nil || !strings.Contains(s.runErr.Error(), "pre-hook") { + s.Fatalf("expected pre-hook failure, got %v", s.runErr) + } +} + +func (s *backupHooksSuite) TheBackupConfigurationIncludesPosthooks() { + s.writeConfig("home", nil, []string{s.postEnvHook("post")}) +} + +func (s *backupHooksSuite) ResticBackupExitsWithCode(a int64) { + s.resticCode = int(a) +} + +func (s *backupHooksSuite) EachPosthookRunsAfterResticBackup() { + lines := s.logLines() + if len(lines) < 2 || !strings.HasPrefix(lines[0], "restic backup") || !strings.HasPrefix(lines[1], "post") { + s.Fatalf("post-hook did not run after restic; log: %#v", lines) + } +} + +func (s *backupHooksSuite) EachPosthookReceivesKeldresticexitcodeSetTo(a string) { + if !s.logContains("code=" + a) { + s.Fatalf("post-hook did not receive exit code %q; log: %#v", a, s.logLines()) + } +} + +func (s *backupHooksSuite) EachPosthookReceivesKeldresticstatusSetTo(a string) { + if !s.logContains("status=" + a) { + s.Fatalf("post-hook did not receive status %q; log: %#v", a, s.logLines()) + } +} + +func (s *backupHooksSuite) KeldExitsWithCode(a int64) { + code := int(a) + if code == 0 { + if s.runErr != nil { + s.Fatalf("expected successful exit, got %v", s.runErr) + } + return + } + + var exitErr interface{ ExitCode() int } + if !errors.As(s.runErr, &exitErr) { + s.Fatalf("expected exit error with code %d, got %T: %v", code, s.runErr, s.runErr) + } + if got := exitErr.ExitCode(); got != code { + s.Fatalf("exit code mismatch: got %d, want %d", got, code) + } +} + +func (s *backupHooksSuite) TheBackupConfigurationIncludesAFailingPosthook() { + s.writeConfig("home", nil, []string{s.logHook("post fail") + "; exit 44"}) +} + +func (s *backupHooksSuite) KeldReportsThePosthookFailure() { + if !strings.Contains(s.runStderr, "post-hook") { + s.Fatalf("expected post-hook failure on stderr, got stdout=%q stderr=%q err=%v", s.runStdout, s.runStderr, s.runErr) + } +} + +func (s *backupHooksSuite) TheBackupConfigurationIncludesPrehooksAndPosthooks() { + s.writeConfig("home", []string{s.logHook("pre preview")}, []string{s.logHook("post preview")}) +} + +func (s *backupHooksSuite) ResticBackupFailsToStart() { + s.writeConfig("home", []string{s.logHook("pre preview")}, []string{s.postEnvHook("post preview")}) + fakeRestic := os.Getenv("KELD_EXECUTABLE") + if err := os.WriteFile(fakeRestic, []byte("#!/definitely/missing/keld-restic-interpreter\n"), 0o755); err != nil { + s.Fatalf("rewriting fake restic: %v", err) + } +} + +func (s *backupHooksSuite) EachPosthookRunsAfterTheFailedResticStart() { + lines := s.logLines() + if len(lines) < 2 || !strings.HasPrefix(lines[0], "pre preview") || !strings.HasPrefix(lines[1], "post preview") { + s.Fatalf("post-hook did not run after failed restic start; log: %#v", lines) + } +} + +func (s *backupHooksSuite) EachPosthookReceivesNoKeldresticexitcode() { + for _, line := range s.logLines() { + if strings.HasPrefix(line, "post") && strings.Contains(line, "code=") && !strings.Contains(line, "code= ") { + s.Fatalf("post-hook unexpectedly received exit code; log: %#v", s.logLines()) + } + } +} + +func (s *backupHooksSuite) KeldReportsTheResticStartFailure() { + if s.runErr == nil || !strings.Contains(s.runErr.Error(), "starting restic") { + s.Fatalf("expected restic start failure, got %v", s.runErr) + } +} + +func (s *backupHooksSuite) KeldShowsTheResolvedBackupCommand() { + flagPreset = s.preset + flagShowCmd = true + flagConfigFile = s.configPath + + var err error + s.runStdout, s.runStderr, err = captureOutput(s, func() error { + return runCommand("backup", lookupSubcommandForHooks(s, "backup"), nil, nil, nil) + }) + s.runErr = err +} + +func (s *backupHooksSuite) TheOutputIncludesTheConfiguredPrehooks() { + if !strings.Contains(s.runStdout, "pre-hooks:") || !strings.Contains(s.runStdout, "pre preview") { + s.Fatalf("preview missing pre-hooks: %s", s.runStdout) + } +} + +func (s *backupHooksSuite) TheOutputIncludesTheConfiguredPosthooks() { + if !strings.Contains(s.runStdout, "post-hooks:") || !strings.Contains(s.runStdout, "post preview") { + s.Fatalf("preview missing post-hooks: %s", s.runStdout) + } +} + +func (s *backupHooksSuite) TheOutputDoesNotIncludeKeldresticexitcode() { + if strings.Contains(s.runStdout, "KELD_RESTIC_EXIT_CODE") { + s.Fatalf("preview included runtime exit code env: %s", s.runStdout) + } +} + +func (s *backupHooksSuite) TheOutputDoesNotIncludeKeldresticstatus() { + if strings.Contains(s.runStdout, "KELD_RESTIC_STATUS") { + s.Fatalf("preview included runtime status env: %s", s.runStdout) + } +} + +func (s *backupHooksSuite) writeConfig(preset string, preHooks, postHooks []string) { + s.preset = preset + var b strings.Builder + b.WriteString("[" + quote(preset) + "]\n") + b.WriteString("tag = \"bdd\"\n\n") + b.WriteString("[" + quote(preset) + ".backup]\n") + b.WriteString("_arguments = [" + quote(filepath.Join(s.tmpDir, "source")) + "]\n") + if len(preHooks) > 0 { + b.WriteString("_pre_hooks = [" + quotedList(preHooks) + "]\n") + } + if len(postHooks) > 0 { + b.WriteString("_post_hooks = [" + quotedList(postHooks) + "]\n") + } + s.writeRawConfig(b.String()) +} + +func (s *backupHooksSuite) writeRawConfig(toml string) { + if err := os.WriteFile(s.configPath, []byte(toml), 0o600); err != nil { + s.Fatalf("writing config: %v", err) + } + flagConfigFile = s.configPath + mustSetenv(s, "KELD_CONFIG_FILE", s.configPath) +} + +func (s *backupHooksSuite) logHook(message string) string { + return "printf " + quote(message+"\n") + " >> \"$KELD_TEST_LOG\"" +} + +func (s *backupHooksSuite) postEnvHook(prefix string) string { + return "printf " + quote(prefix+" code=%s status=%s\n") + " \"$KELD_RESTIC_EXIT_CODE\" \"$KELD_RESTIC_STATUS\" >> \"$KELD_TEST_LOG\"" +} + +func (s *backupHooksSuite) logLines() []string { + data, err := os.ReadFile(s.logPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + s.Fatalf("reading log: %v", err) + } + return strings.Split(strings.TrimRight(string(data), "\n"), "\n") +} + +func (s *backupHooksSuite) logContains(needle string) bool { + for _, line := range s.logLines() { + if strings.Contains(line, needle) { + return true + } + } + return false +} + +func quote(s string) string { return strconv.Quote(s) } + +func quotedList(values []string) string { + quoted := make([]string, len(values)) + for i, value := range values { + quoted[i] = quote(value) + } + return strings.Join(quoted, ", ") +} + +func mustSetenv(s *backupHooksSuite, key, value string) { + if err := os.Setenv(key, value); err != nil { + s.Fatalf("setting %s: %v", key, err) + } +} + +func restoreEnv(key, value string) { + if value == "" { + _ = os.Unsetenv(key) + return + } + _ = os.Setenv(key, value) +} + +func lookupSubcommandForHooks(s *backupHooksSuite, name string) *cobra.Command { + s.Helper() + for _, cmd := range rootCmd.Commands() { + if cmd.Name() == name { + return cmd + } + } + s.Fatalf("subcommand %q not found", name) + return nil +} + +func captureOutput(s *backupHooksSuite, run func() error) (string, string, error) { + s.Helper() + + oldStdout := os.Stdout + oldStderr := os.Stderr + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + s.Fatalf("creating stdout pipe: %v", err) + } + stderrReader, stderrWriter, err := os.Pipe() + if err != nil { + s.Fatalf("creating stderr pipe: %v", err) + } + os.Stdout = stdoutWriter + os.Stderr = stderrWriter + + var stdoutBuf, stderrBuf bytes.Buffer + var wg sync.WaitGroup + var stdoutErr, stderrErr error + wg.Add(2) + go func() { + defer wg.Done() + _, stdoutErr = io.Copy(&stdoutBuf, stdoutReader) + }() + go func() { + defer wg.Done() + _, stderrErr = io.Copy(&stderrBuf, stderrReader) + }() + + runErr := run() + _ = stdoutWriter.Close() + _ = stderrWriter.Close() + os.Stdout = oldStdout + os.Stderr = oldStderr + wg.Wait() + + if stdoutErr != nil { + s.Fatalf("reading stdout: %v", stdoutErr) + } + if stderrErr != nil { + s.Fatalf("reading stderr: %v", stderrErr) + } + _ = stdoutReader.Close() + _ = stderrReader.Close() + + return stdoutBuf.String(), stderrBuf.String(), runErr +} diff --git a/cmd/root.go b/cmd/root.go index d64bbe2d32905b4677c41accd43cb54f3b1488ff..75ceffdfbbd413b963f8844b02074e0d75c966fa 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "slices" @@ -173,6 +174,10 @@ func mergeOverrides(base, extra map[string][]string) map[string][]string { // Execute is the main entry point, called from main.go. func Execute() { if err := fang.Execute(context.Background(), rootCmd); err != nil { + var exitErr interface{ ExitCode() int } + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + } os.Exit(1) } } diff --git a/examples/README.md b/examples/README.md index ec17a54f1047caf555ba68e000107d1a5289388c..78d3bd977b200c42feb8efeb8c8a28ffece70ff0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -20,6 +20,39 @@ Open `~/.config/keld/config.toml` and customize it for your backups: - Configure environment variables for authentication - Set backup sources via `_arguments` +Backup presets can also define shell hooks: + +```toml +["media@".backup] +_arguments = ["/home/user/Music/Final"] +_pre_hooks = ["echo preparing media backup"] +_post_hooks = ["echo backup finished with $KELD_RESTIC_STATUS"] +``` + +`_pre_hooks` run before `restic backup`. If a pre-hook fails, restic is not +started and post-hooks do not run. `_post_hooks` run after restic is attempted, +even when restic fails. Post-hooks receive `KELD_RESTIC_STATUS` and, when restic +started, `KELD_RESTIC_EXIT_CODE`. Use `keld --show-command --preset +backup` to preview configured hooks without running them. + +Keld status names are based on [restic's documented exit +codes](https://restic.readthedocs.io/en/stable/075_scripting.html#exit-codes), +with one extra status for restic start failures: + +| Restic exit code | `KELD_RESTIC_STATUS` | Meaning | +| ---------------- | -------------------- | ------- | +| 0 | `success` | Restic completed successfully. | +| 1 | `fatal` | Restic failed; see restic output for details. | +| 2 | `runtime_error` | Go runtime error. | +| 3 | `partial` | `backup` could not read some source data. | +| 10 | `repository_missing` | Repository does not exist. | +| 11 | `locked` | Restic failed to lock the repository. | +| 12 | `wrong_password` | Repository password was wrong. | +| 130 | `interrupted` | Restic was interrupted. | +| 143 | `terminated` | Restic was terminated by SIGTERM. | +| other non-zero | `unknown_failure` | Unknown restic failure; treat as failed. | +| restic not started | `start_failed` | Keld could not start the restic process. | + Repeat this to add backups later. ### 3. Set up healthchecks.io (optional) diff --git a/examples/keld/config.toml b/examples/keld/config.toml index 949f017321053008c647145533dd5bee3f511b83..a399522fc8c4f83b52e555ff44607ddfda235fb5 100644 --- a/examples/keld/config.toml +++ b/examples/keld/config.toml @@ -33,6 +33,10 @@ RESTIC_PASSWORD_COMMAND = "op read 'op://Vault/Media Backup/password'" ["media@".backup] _arguments = ["/home/user/Music/Final"] +# Optional backup hooks are shell commands run around `restic backup`. +# A failing pre-hook stops the backup; post-hooks run after restic is attempted. +# _pre_hooks = ["echo preparing generated backup inputs"] +# _post_hooks = ["echo backup finished with $KELD_RESTIC_STATUS"] # ── Where to back up ───────────────────────────────────────── # diff --git a/examples/keld/config_long.toml b/examples/keld/config_long.toml index 40f901eae6ea40e8b6cc1d0eb3d2cc80caa19521..369b643ced90f1111a2aa27e104d4fd77bfd8d62 100644 --- a/examples/keld/config_long.toml +++ b/examples/keld/config_long.toml @@ -19,6 +19,18 @@ exclude-if-present = ".nobackup" # Simple preset: `keld --preset home backup` _arguments = ["/home/amolith"] tag = ["home"] +# Backup hooks are shell commands run around `restic backup`. +# Pre-hooks can prepare generated inputs; post-hooks can clean them up or report +# the outcome. Post-hooks receive KELD_RESTIC_STATUS and, when restic started, +# KELD_RESTIC_EXIT_CODE. +# _pre_hooks = [ +# "mkdir -p /home/amolith/.cache/keld/dumps", +# "sqlite3 /home/amolith/app/app.db '.backup /home/amolith/.cache/keld/dumps/app.db'", +# ] +# _post_hooks = [ +# "rm -f /home/amolith/.cache/keld/dumps/app.db", +# "echo backup finished with $KELD_RESTIC_STATUS", +# ] ["@nas"] # Split preset suffix: applies to `* @nas` style presets. @@ -44,6 +56,11 @@ tag = ["home"] # Prefix + command section. _arguments = ["/home/amolith", "/etc"] exclude-if-present = ".keld-skip" +# Hook keys follow normal config precedence. Defining them here would replace +# hooks inherited from lower-priority sections; an empty list clears inherited +# hooks for matching presets. +# _pre_hooks = [] +# _post_hooks = [] ["home@".forget] # Prefix + different command section. diff --git a/features/backup_hooks.feature b/features/backup_hooks.feature new file mode 100644 index 0000000000000000000000000000000000000000..5aeea7b3afdd8299a26d18ebf172bb98fe7884ca --- /dev/null +++ b/features/backup_hooks.feature @@ -0,0 +1,96 @@ +Feature: Backup hooks + + Keld can run backup-specific shell commands before and after restic backup. + These hooks let users prepare generated backup inputs, such as database + dumps, and clean up generated files after restic has attempted the backup. + + Background: + Given the selected preset supplies backup paths + + Rule: Backup hooks are resolved by the most-specific matching configuration + + Scenario: Most-specific hook keys override inherited hook keys + Given backup hooks are configured in multiple matching sections + When the backup configuration is resolved + Then the pre-hooks come from the most-specific section that defines pre-hooks + And the post-hooks come from the most-specific section that defines post-hooks + And the hooks are not passed to restic as flags + + Scenario: Omitted hook keys inherit less-specific hooks + Given backup hooks are configured in a less-specific matching section + And the most-specific matching section omits hook keys + When the backup configuration is resolved + Then the inherited pre-hooks are used + And the inherited post-hooks are used + + Scenario: Empty hook lists clear inherited hooks + Given backup hooks are configured in a less-specific matching section + And the most-specific matching section clears hook keys + When the backup configuration is resolved + Then no pre-hooks are configured + And no post-hooks are configured + + Rule: Pre-hooks run before restic backup + + Scenario: Pre-hooks complete successfully + Given the backup configuration includes pre-hooks + When keld runs the backup + Then each pre-hook runs before restic backup + And restic backup is attempted + + Scenario: A pre-hook fails + Given the backup configuration includes a failing pre-hook + When keld runs the backup + Then restic backup is not attempted + And post-hooks are not run + And keld reports the pre-hook failure + + Rule: Post-hooks run after restic backup is attempted + + Scenario Outline: Post-hooks receive the restic outcome + Given the backup configuration includes post-hooks + And restic backup exits with code + When keld runs the backup + Then each post-hook runs after restic backup + And each post-hook receives KELD_RESTIC_EXIT_CODE set to "" + And each post-hook receives KELD_RESTIC_STATUS set to "" + And keld exits with code + + Examples: + | code | status | + | 0 | success | + | 1 | fatal | + | 2 | runtime_error | + | 3 | partial | + | 10 | repository_missing | + | 11 | locked | + | 12 | wrong_password | + | 130 | interrupted | + | 143 | terminated | + | 99 | unknown_failure | + + Scenario: A post-hook fails after restic fails + Given the backup configuration includes a failing post-hook + And restic backup exits with code 3 + When keld runs the backup + Then keld exits with code 3 + And keld reports the post-hook failure + + Scenario: Restic fails to start after pre-hooks complete + Given the backup configuration includes pre-hooks and post-hooks + And restic backup fails to start + When keld runs the backup + Then each post-hook runs after the failed restic start + And each post-hook receives KELD_RESTIC_STATUS set to "start_failed" + And each post-hook receives no KELD_RESTIC_EXIT_CODE + And keld reports the restic start failure + + Rule: Command previews show hooks without runtime-only outcome values + + Scenario: Showing the command for a backup with hooks + Given the backup configuration includes pre-hooks and post-hooks + When keld shows the resolved backup command + Then the output includes the configured pre-hooks + And the output includes the configured post-hooks + And the output does not include KELD_RESTIC_EXIT_CODE + And the output does not include KELD_RESTIC_STATUS diff --git a/go.mod b/go.mod index 7718ff5aa3aab0fdc21bfa09ca2c8d852612cea1..6d24255f93aaccb3922d2bd8c2c2be487f17b79c 100644 --- a/go.mod +++ b/go.mod @@ -12,12 +12,14 @@ require ( github.com/charmbracelet/x/term v0.2.2 github.com/dustin/go-humanize v1.0.1 github.com/lrstanley/bubbletint/v2 v2.0.1 + github.com/regen-network/gocuke v1.1.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 ) require ( github.com/atotto/clipboard v0.1.4 // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect @@ -29,6 +31,12 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cockroachdb/apd/v3 v3.2.1 // indirect + github.com/cucumber/gherkin/go/v27 v27.0.0 // indirect + github.com/cucumber/messages/go/v22 v22.0.0 // indirect + github.com/cucumber/tag-expressions/go/v6 v6.1.0 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect @@ -43,4 +51,6 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.24.0 // indirect + gotest.tools/v3 v3.5.1 // indirect + pgregory.net/rapid v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index b9c9e7509302e2e51a49a743624977723a7a33ae..9e78b10d545e4d1b5046db8341388303795fa8a9 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= @@ -48,15 +50,29 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/cucumber/gherkin/go/v27 v27.0.0 h1:waJh5eeq7rrKn5Gf3/FI4G34ypduPRaV8e370dnupDI= +github.com/cucumber/gherkin/go/v27 v27.0.0/go.mod h1:2JxwYskO0sO4kumc/Nv1g6bMncT5w0lShuKZnmUIhhk= +github.com/cucumber/messages/go/v22 v22.0.0 h1:hk3ITpEWQ+KWDe619zYcqtaLOfcu9jgClSeps3DlNWI= +github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs= +github.com/cucumber/tag-expressions/go/v6 v6.1.0 h1:YOhnlISh/lyPZrLojFbJVzocv7TGhzOhB9aULN8A7Sg= +github.com/cucumber/tag-expressions/go/v6 v6.1.0/go.mod h1:6scGHUy3RLnbNq8un7XNoopF2qR/0RMgqolQH/TkycY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lrstanley/bubbletint/v2 v2.0.1 h1:ELxRFzrm9X5DIz7Y1Yp0gfGhsJo+4U3w8WJe6x/Beso= github.com/lrstanley/bubbletint/v2 v2.0.1/go.mod h1:fL833lvIEbec7VBi9F8wZ/1008jBiDrvQtuIac9AG/k= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -77,6 +93,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/regen-network/gocuke v1.1.1 h1:13D3n5xLbpzA/J2ELHC9jXYq0+XyEr64A3ehjvfmBbE= +github.com/regen-network/gocuke v1.1.1/go.mod h1:Nl9EbhLmTzdLqb52fr/Fvf8LcoVuTjjf8FlLmXz1zHo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -100,3 +118,7 @@ golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +pgregory.net/rapid v1.1.0 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw= +pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/config/config.go b/internal/config/config.go index 148846159a082c1995cd8780cf393e4914eb507d..581e1890212a9b314084d1f1fc9e2827a461c6b1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,8 @@ const ( keyArguments = "_arguments" keyWorkdir = "_workdir" keyCommand = "_command" + keyPreHooks = "_pre_hooks" + keyPostHooks = "_post_hooks" ) // environSuffix marks a section as containing environment variables. @@ -41,6 +43,12 @@ type ResolvedConfig struct { // Environ holds additional environment variables for the restic process. Environ map[string]string + // PreHooks are shell commands to run before restic backup. + PreHooks []string + + // PostHooks are shell commands to run after restic backup is attempted. + PostHooks []string + // SectionsRead lists which config sections contributed to this resolution. SectionsRead []string } @@ -335,6 +343,16 @@ func assemble(merged map[string]any, command string, environ map[string]string, rc.Command = fmt.Sprint(cmd) delete(merged, keyCommand) } + if command == "backup" { + if hooks, ok := merged[keyPreHooks]; ok { + rc.PreHooks = toStringSlice(hooks) + } + if hooks, ok := merged[keyPostHooks]; ok { + rc.PostHooks = toStringSlice(hooks) + } + } + delete(merged, keyPreHooks) + delete(merged, keyPostHooks) // Build flags. for k, v := range merged { diff --git a/internal/restic/exec.go b/internal/restic/exec.go index 8eb753c4f9ead4639e30094448c91f4537dbd524..9fa0b60a17c7ff8570306ee0d5eb7ba93c5237e0 100644 --- a/internal/restic/exec.go +++ b/internal/restic/exec.go @@ -2,12 +2,12 @@ package restic import ( "bytes" + "errors" "fmt" "os" "os/exec" "sort" "strings" - "syscall" "git.secluded.site/keld/internal/config" ) @@ -23,7 +23,25 @@ var resticNativeCommands = map[string]bool{ // unset. const DefaultExecutable = "restic" -// Run replaces the current process with restic, configured according to cfg. +// ExitError reports that restic exited unsuccessfully while preserving its +// numeric exit code for callers that need to mirror restic's process status. +type ExitError struct { + Code int + Err error +} + +func (e *ExitError) Error() string { + if e.Err == nil { + return fmt.Sprintf("restic exited with code %d", e.Code) + } + return fmt.Sprintf("restic exited with code %d: %v", e.Code, e.Err) +} + +func (e *ExitError) Unwrap() error { return e.Err } + +func (e *ExitError) ExitCode() int { return e.Code } + +// Run supervises restic, configured according to cfg. func Run(cfg *config.ResolvedConfig) error { exe := executable() @@ -32,20 +50,79 @@ func Run(cfg *config.ResolvedConfig) error { return fmt.Errorf("finding %s: %w", exe, err) } + if err := resolveEnvironCommands(cfg.Environ, cfg.Workdir); err != nil { + return err + } + + argv := buildArgv(exe, cfg) + env := buildEnv(cfg.Environ) + + for _, hook := range cfg.PreHooks { + if err := runHook(hook, cfg.Workdir, env); err != nil { + return fmt.Errorf("pre-hook %q: %w", hook, err) + } + } + + cmd := exec.Command(path, argv[1:]...) //nolint:gosec + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr if cfg.Workdir != "" { - if err := os.Chdir(cfg.Workdir); err != nil { - return fmt.Errorf("chdir %s: %w", cfg.Workdir, err) + cmd.Dir = cfg.Workdir + } + + startErr := cmd.Start() + var restErr error + restCode := 0 + restStarted := startErr == nil + if startErr == nil { + stopSignals := watchResticSignals(cmd.Process) + restErr = cmd.Wait() + stopSignals() + if restErr != nil { + var exitErr *exec.ExitError + if errors.As(restErr, &exitErr) { + restCode = resticExitCode(exitErr) + } else { + restErr = fmt.Errorf("running restic: %w", restErr) + } } + } else { + restErr = fmt.Errorf("starting restic: %w", startErr) } - if err := resolveEnvironCommands(cfg.Environ); err != nil { - return err + postEnv := append([]string(nil), env...) + if restStarted { + postEnv = append( + postEnv, + fmt.Sprintf("KELD_RESTIC_EXIT_CODE=%d", restCode), + "KELD_RESTIC_STATUS="+ResticStatus(restCode), + ) + } else { + postEnv = append(postEnv, "KELD_RESTIC_STATUS=start_failed") + } + var postErr error + for _, hook := range cfg.PostHooks { + if err := runHook(hook, cfg.Workdir, postEnv); err != nil { + if postErr == nil { + postErr = err + } + fmt.Fprintf(os.Stderr, "post-hook %q failed: %v\n", hook, err) + } } - argv := buildArgv(exe, cfg) - env := buildEnv(cfg.Environ) + if restErr != nil { + if !restStarted { + return restErr + } + return &ExitError{Code: restCode, Err: restErr} + } + if postErr != nil { + return fmt.Errorf("post-hook: %w", postErr) + } - return syscall.Exec(path, argv, env) + return nil } // DryRun formats a human-readable summary of what Run would execute. @@ -75,6 +152,19 @@ func DryRun(cfg *config.ResolvedConfig) string { } } + if len(cfg.PreHooks) > 0 { + fmt.Fprintln(&b, "pre-hooks:") + for _, hook := range cfg.PreHooks { + fmt.Fprintf(&b, " %s\n", hook) + } + } + if len(cfg.PostHooks) > 0 { + fmt.Fprintln(&b, "post-hooks:") + for _, hook := range cfg.PostHooks { + fmt.Fprintf(&b, " %s\n", hook) + } + } + argv := buildArgv(executable(), cfg) fmt.Fprintf(&b, "command: %s\n", quotedJoin(argv)) @@ -108,6 +198,46 @@ func buildArgv(exe string, cfg *config.ResolvedConfig) []string { return argv } +func runHook(hook, workdir string, env []string) error { + cmd := exec.Command("sh", "-c", hook) //nolint:gosec + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if workdir != "" { + cmd.Dir = workdir + } + return cmd.Run() +} + +// ResticStatus maps documented restic backup exit codes to script-friendly +// status labels. Unknown non-zero exit codes are failures by restic's +// documented scripting contract. +func ResticStatus(code int) string { + switch code { + case 0: + return "success" + case 1: + return "fatal" + case 2: + return "runtime_error" + case 3: + return "partial" + case 10: + return "repository_missing" + case 11: + return "locked" + case 12: + return "wrong_password" + case 130: + return "interrupted" + case 143: + return "terminated" + default: + return "unknown_failure" + } +} + // resolveEnvironCommands finds keys ending in _COMMAND in the environ map, // executes their values as shell commands, and replaces them with the base key // set to the command's stdout (trailing newline stripped). Keys that restic @@ -115,7 +245,7 @@ func buildArgv(exe string, cfg *config.ResolvedConfig) []string { // // If both FOO_COMMAND and FOO are present, the command result takes precedence // and FOO_COMMAND is removed from the map. -func resolveEnvironCommands(environ map[string]string) error { +func resolveEnvironCommands(environ map[string]string, workdir string) error { for key, cmd := range environ { if !strings.HasSuffix(key, config.CommandSuffix) { continue @@ -133,6 +263,9 @@ func resolveEnvironCommands(environ map[string]string) error { proc := exec.Command("sh", "-c", cmd) proc.Stdout = &stdout proc.Stderr = &stderr + if workdir != "" { + proc.Dir = workdir + } if err := proc.Run(); err != nil { return fmt.Errorf( diff --git a/internal/restic/exec_test.go b/internal/restic/exec_test.go index 6761338f3a2f8c5716085d45b3866b0d6c4345dd..c53a7f39d09b4ee3c64d2f06addfb98aa6e01330 100644 --- a/internal/restic/exec_test.go +++ b/internal/restic/exec_test.go @@ -236,7 +236,7 @@ func TestResolveEnvironCommands(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := resolveEnvironCommands(tt.environ) + err := resolveEnvironCommands(tt.environ, "") if tt.wantError != "" { if err == nil { diff --git a/internal/restic/list_snapshots.go b/internal/restic/list_snapshots.go index 1312840343f7c7d1f7479fd7c8406fbbc001019f..1ac6c151152ae381d2b662de132cafa56b9bf441 100644 --- a/internal/restic/list_snapshots.go +++ b/internal/restic/list_snapshots.go @@ -111,7 +111,7 @@ func ListSnapshots(cfg *config.ResolvedConfig) ([]Snapshot, error) { } env := copyEnviron(cfg.Environ) - if err := resolveEnvironCommands(env); err != nil { + if err := resolveEnvironCommands(env, cfg.Workdir); err != nil { return nil, fmt.Errorf("resolving environ for snapshot listing: %w", err) } diff --git a/internal/restic/lsnodes.go b/internal/restic/lsnodes.go index d78e4c3c1e46d4390e241882ef2e9d660e2d4df1..c45d6b0fbae153be60a85e15adbde69f15b5ee13 100644 --- a/internal/restic/lsnodes.go +++ b/internal/restic/lsnodes.go @@ -88,7 +88,7 @@ func RunLs(cfg *config.ResolvedConfig, snapshotID string) ([]LsNode, error) { } env := copyEnviron(cfg.Environ) - if err := resolveEnvironCommands(env); err != nil { + if err := resolveEnvironCommands(env, cfg.Workdir); err != nil { return nil, fmt.Errorf("resolving environ for ls: %w", err) } diff --git a/internal/restic/signal_other.go b/internal/restic/signal_other.go new file mode 100644 index 0000000000000000000000000000000000000000..f30fee07ca9372f386501061eaecb8ede7eb4479 --- /dev/null +++ b/internal/restic/signal_other.go @@ -0,0 +1,33 @@ +//go:build !unix + +package restic + +import ( + "os" + "os/exec" + "os/signal" +) + +func watchResticSignals(process *os.Process) func() { + _ = process + + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt) + done := make(chan struct{}) + + go func() { + defer close(done) + for range signals { + } + }() + + return func() { + signal.Stop(signals) + close(signals) + <-done + } +} + +func resticExitCode(exitErr *exec.ExitError) int { + return exitErr.ExitCode() +} diff --git a/internal/restic/signal_unix.go b/internal/restic/signal_unix.go new file mode 100644 index 0000000000000000000000000000000000000000..c6760700e955c50e555f95b6768cec75eed90ba0 --- /dev/null +++ b/internal/restic/signal_unix.go @@ -0,0 +1,43 @@ +//go:build unix + +package restic + +import ( + "os" + "os/exec" + "os/signal" + "syscall" +) + +func watchResticSignals(process *os.Process) func() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + done := make(chan struct{}) + + go func() { + defer close(done) + for sig := range signals { + if sig == syscall.SIGTERM { + _ = process.Signal(sig) + } + } + }() + + return func() { + signal.Stop(signals) + close(signals) + <-done + } +} + +func resticExitCode(exitErr *exec.ExitError) int { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { + if status.Signaled() { + return 128 + int(status.Signal()) + } + if status.Exited() { + return status.ExitStatus() + } + } + return exitErr.ExitCode() +}