exec.go

  1package restic
  2
  3import (
  4	"bytes"
  5	"errors"
  6	"fmt"
  7	"os"
  8	"os/exec"
  9	"sort"
 10	"strings"
 11
 12	"git.secluded.site/keld/internal/config"
 13)
 14
 15// resticNativeCommands lists .environ keys that restic handles natively.
 16// Keld passes these through as-is rather than executing them.
 17var resticNativeCommands = map[string]bool{
 18	"RESTIC_PASSWORD_COMMAND":      true,
 19	"RESTIC_FROM_PASSWORD_COMMAND": true,
 20}
 21
 22// DefaultExecutable is the restic binary name used when KELD_EXECUTABLE is
 23// unset.
 24const DefaultExecutable = "restic"
 25
 26// ExitError reports that restic exited unsuccessfully while preserving its
 27// numeric exit code for callers that need to mirror restic's process status.
 28type ExitError struct {
 29	Code int
 30	Err  error
 31}
 32
 33func (e *ExitError) Error() string {
 34	if e.Err == nil {
 35		return fmt.Sprintf("restic exited with code %d", e.Code)
 36	}
 37	return fmt.Sprintf("restic exited with code %d: %v", e.Code, e.Err)
 38}
 39
 40func (e *ExitError) Unwrap() error { return e.Err }
 41
 42func (e *ExitError) ExitCode() int { return e.Code }
 43
 44// Run supervises restic, configured according to cfg.
 45func Run(cfg *config.ResolvedConfig) error {
 46	exe := executable()
 47
 48	path, err := exec.LookPath(exe)
 49	if err != nil {
 50		return fmt.Errorf("finding %s: %w", exe, err)
 51	}
 52
 53	if err := resolveEnvironCommands(cfg.Environ, cfg.Workdir); err != nil {
 54		return err
 55	}
 56
 57	argv := buildArgv(exe, cfg)
 58	env := buildEnv(cfg.Environ)
 59
 60	for _, hook := range cfg.PreHooks {
 61		if err := runHook(hook, cfg.Workdir, env); err != nil {
 62			return fmt.Errorf("pre-hook %q: %w", hook, err)
 63		}
 64	}
 65
 66	cmd := exec.Command(path, argv[1:]...) //nolint:gosec
 67	cmd.Env = env
 68	cmd.Stdin = os.Stdin
 69	cmd.Stdout = os.Stdout
 70	cmd.Stderr = os.Stderr
 71	if cfg.Workdir != "" {
 72		cmd.Dir = cfg.Workdir
 73	}
 74
 75	startErr := cmd.Start()
 76	var restErr error
 77	restCode := 0
 78	restStarted := startErr == nil
 79	if startErr == nil {
 80		stopSignals := watchResticSignals(cmd.Process)
 81		restErr = cmd.Wait()
 82		stopSignals()
 83		if restErr != nil {
 84			var exitErr *exec.ExitError
 85			if errors.As(restErr, &exitErr) {
 86				restCode = resticExitCode(exitErr)
 87			} else {
 88				restErr = fmt.Errorf("running restic: %w", restErr)
 89			}
 90		}
 91	} else {
 92		restErr = fmt.Errorf("starting restic: %w", startErr)
 93	}
 94
 95	postEnv := append([]string(nil), env...)
 96	if restStarted {
 97		postEnv = append(
 98			postEnv,
 99			fmt.Sprintf("KELD_RESTIC_EXIT_CODE=%d", restCode),
100			"KELD_RESTIC_STATUS="+ResticStatus(restCode),
101		)
102	} else {
103		postEnv = append(postEnv, "KELD_RESTIC_STATUS=start_failed")
104	}
105	var postErr error
106	for _, hook := range cfg.PostHooks {
107		if err := runHook(hook, cfg.Workdir, postEnv); err != nil {
108			if postErr == nil {
109				postErr = err
110			}
111			fmt.Fprintf(os.Stderr, "post-hook %q failed: %v\n", hook, err)
112		}
113	}
114
115	if restErr != nil {
116		if !restStarted {
117			return restErr
118		}
119		return &ExitError{Code: restCode, Err: restErr}
120	}
121	if postErr != nil {
122		return fmt.Errorf("post-hook: %w", postErr)
123	}
124
125	return nil
126}
127
128// DryRun formats a human-readable summary of what Run would execute.
129func DryRun(cfg *config.ResolvedConfig) string {
130	var b strings.Builder
131
132	if len(cfg.SectionsRead) > 0 {
133		fmt.Fprintf(&b, "config sections: %s\n", strings.Join(cfg.SectionsRead, " → "))
134	}
135
136	if cfg.Workdir != "" {
137		fmt.Fprintf(&b, "workdir: %s\n", cfg.Workdir)
138	}
139
140	if len(cfg.Environ) > 0 {
141		fmt.Fprintln(&b, "environ:")
142
143		// Sort keys for deterministic output.
144		keys := make([]string, 0, len(cfg.Environ))
145		for k := range cfg.Environ {
146			keys = append(keys, k)
147		}
148		sort.Strings(keys)
149
150		for _, k := range keys {
151			fmt.Fprintf(&b, "  %s=%s\n", k, cfg.Environ[k])
152		}
153	}
154
155	if len(cfg.PreHooks) > 0 {
156		fmt.Fprintln(&b, "pre-hooks:")
157		for _, hook := range cfg.PreHooks {
158			fmt.Fprintf(&b, "  %s\n", hook)
159		}
160	}
161	if len(cfg.PostHooks) > 0 {
162		fmt.Fprintln(&b, "post-hooks:")
163		for _, hook := range cfg.PostHooks {
164			fmt.Fprintf(&b, "  %s\n", hook)
165		}
166	}
167
168	argv := buildArgv(executable(), cfg)
169	fmt.Fprintf(&b, "command: %s\n", quotedJoin(argv))
170
171	return b.String()
172}
173
174// executable returns the restic binary name, respecting KELD_EXECUTABLE.
175func executable() string {
176	if e := os.Getenv("KELD_EXECUTABLE"); e != "" {
177		return config.ExpandPath(e)
178	}
179	return DefaultExecutable
180}
181
182// buildArgv assembles the full argument vector for the restic process.
183func buildArgv(exe string, cfg *config.ResolvedConfig) []string {
184	argv := []string{exe}
185
186	if cfg.Command != "" {
187		argv = append(argv, cfg.Command)
188	}
189
190	for _, f := range cfg.Flags {
191		argv = append(argv, f.Name)
192		if f.Value != "" {
193			argv = append(argv, f.Value)
194		}
195	}
196
197	argv = append(argv, cfg.Arguments...)
198	return argv
199}
200
201func runHook(hook, workdir string, env []string) error {
202	cmd := exec.Command("sh", "-c", hook) //nolint:gosec
203	cmd.Env = env
204	cmd.Stdin = os.Stdin
205	cmd.Stdout = os.Stdout
206	cmd.Stderr = os.Stderr
207	if workdir != "" {
208		cmd.Dir = workdir
209	}
210	return cmd.Run()
211}
212
213// ResticStatus maps documented restic backup exit codes to script-friendly
214// status labels. Unknown non-zero exit codes are failures by restic's
215// documented scripting contract.
216func ResticStatus(code int) string {
217	switch code {
218	case 0:
219		return "success"
220	case 1:
221		return "fatal"
222	case 2:
223		return "runtime_error"
224	case 3:
225		return "partial"
226	case 10:
227		return "repository_missing"
228	case 11:
229		return "locked"
230	case 12:
231		return "wrong_password"
232	case 130:
233		return "interrupted"
234	case 143:
235		return "terminated"
236	default:
237		return "unknown_failure"
238	}
239}
240
241// resolveEnvironCommands finds keys ending in _COMMAND in the environ map,
242// executes their values as shell commands, and replaces them with the base key
243// set to the command's stdout (trailing newline stripped). Keys that restic
244// handles natively (like RESTIC_PASSWORD_COMMAND) are left untouched.
245//
246// If both FOO_COMMAND and FOO are present, the command result takes precedence
247// and FOO_COMMAND is removed from the map.
248func resolveEnvironCommands(environ map[string]string, workdir string) error {
249	for key, cmd := range environ {
250		if !strings.HasSuffix(key, config.CommandSuffix) {
251			continue
252		}
253		if resticNativeCommands[key] {
254			continue
255		}
256
257		baseKey := strings.TrimSuffix(key, config.CommandSuffix)
258		if baseKey == "" {
259			continue
260		}
261
262		var stdout, stderr bytes.Buffer
263		proc := exec.Command("sh", "-c", cmd)
264		proc.Stdout = &stdout
265		proc.Stderr = &stderr
266		if workdir != "" {
267			proc.Dir = workdir
268		}
269
270		if err := proc.Run(); err != nil {
271			return fmt.Errorf(
272				"environ %s: running %q: %w\nstderr: %s",
273				key, cmd, err, strings.TrimSpace(stderr.String()),
274			)
275		}
276
277		environ[baseKey] = strings.TrimRight(stdout.String(), "\n")
278		delete(environ, key)
279	}
280	return nil
281}
282
283// buildEnv merges extra environment variables into the current process env.
284func buildEnv(extra map[string]string) []string {
285	env := os.Environ()
286	for k, v := range extra {
287		env = append(env, k+"="+v)
288	}
289	return env
290}
291
292// quotedJoin formats an argv slice by wrapping every element in double
293// quotes. This makes the dry-run output unambiguous — each argument
294// boundary is visible even when values contain spaces.
295func quotedJoin(argv []string) string {
296	var b strings.Builder
297	for i, arg := range argv {
298		if i > 0 {
299			b.WriteByte(' ')
300		}
301		b.WriteByte('"')
302		b.WriteString(arg)
303		b.WriteByte('"')
304	}
305	return b.String()
306}