Detailed changes
@@ -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
@@ -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
+}
@@ -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)
}
}
@@ -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 <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)
@@ -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 βββββββββββββββββββββββββββββββββββββββββ
#
@@ -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.
@@ -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 <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 "<code>"
+ And each post-hook receives KELD_RESTIC_STATUS set to "<status>"
+ And keld exits with code <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
@@ -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
)
@@ -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=
@@ -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 {
@@ -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(
@@ -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 {
@@ -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)
}
@@ -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)
}
@@ -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()
+}
@@ -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()
+}