1package cmd
2
3import (
4 "bytes"
5 "errors"
6 "io"
7 "os"
8 "path/filepath"
9 "reflect"
10 "strconv"
11 "strings"
12 "sync"
13 "testing"
14
15 "github.com/regen-network/gocuke"
16 "github.com/spf13/cobra"
17
18 "git.secluded.site/keld/internal/config"
19)
20
21func TestBackupHooksFeature(t *testing.T) {
22 gocuke.NewRunner(t, &backupHooksSuite{}).
23 Path("../features/backup_hooks.feature").
24 NonParallel().
25 Run()
26}
27
28type backupHooksSuite struct {
29 gocuke.TestingT
30
31 tmpDir string
32 configPath string
33 logPath string
34 preset string
35 resticCode int
36 resolved *config.ResolvedConfig
37 runErr error
38 runStdout string
39 runStderr string
40 oldPreset string
41 oldShowCmd bool
42 oldConfig string
43 oldDryRun string
44 oldConfigEnv string
45 oldExec string
46 oldTestLog string
47 oldTestExit string
48}
49
50func (s *backupHooksSuite) Before() {
51 var err error
52 s.tmpDir, err = os.MkdirTemp("", "keld-backup-hooks-*")
53 if err != nil {
54 s.Fatalf("creating temp dir: %v", err)
55 }
56 s.configPath = filepath.Join(s.tmpDir, "config.toml")
57 s.logPath = filepath.Join(s.tmpDir, "events.log")
58 s.preset = "home"
59 s.resticCode = 0
60
61 s.oldPreset, s.oldShowCmd, s.oldConfig = flagPreset, flagShowCmd, flagConfigFile
62 s.oldDryRun = os.Getenv("KELD_DRYRUN")
63 s.oldConfigEnv = os.Getenv("KELD_CONFIG_FILE")
64 s.oldExec = os.Getenv("KELD_EXECUTABLE")
65 s.oldTestLog = os.Getenv("KELD_TEST_LOG")
66 s.oldTestExit = os.Getenv("KELD_TEST_RESTIC_EXIT")
67
68 fakeRestic := filepath.Join(s.tmpDir, "restic")
69 script := `#!/bin/sh
70printf 'restic %s\n' "$*" >> "$KELD_TEST_LOG"
71exit "${KELD_TEST_RESTIC_EXIT:-0}"
72`
73 if err := os.WriteFile(fakeRestic, []byte(script), 0o755); err != nil {
74 s.Fatalf("writing fake restic: %v", err)
75 }
76 mustSetenv(s, "KELD_EXECUTABLE", fakeRestic)
77 mustSetenv(s, "KELD_TEST_LOG", s.logPath)
78 mustSetenv(s, "KELD_TEST_RESTIC_EXIT", "0")
79}
80
81func (s *backupHooksSuite) After() {
82 flagPreset, flagShowCmd, flagConfigFile = s.oldPreset, s.oldShowCmd, s.oldConfig
83 restoreEnv("KELD_DRYRUN", s.oldDryRun)
84 restoreEnv("KELD_CONFIG_FILE", s.oldConfigEnv)
85 restoreEnv("KELD_EXECUTABLE", s.oldExec)
86 restoreEnv("KELD_TEST_LOG", s.oldTestLog)
87 restoreEnv("KELD_TEST_RESTIC_EXIT", s.oldTestExit)
88 _ = os.RemoveAll(s.tmpDir)
89}
90
91func (s *backupHooksSuite) TheSelectedPresetSuppliesBackupPaths() {
92 s.writeConfig("home", nil, nil)
93}
94
95func (s *backupHooksSuite) BackupHooksAreConfiguredInMultipleMatchingSections() {
96 s.preset = "home@cloud"
97 s.writeRawConfig(`
98[global.backup]
99_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `]
100_pre_hooks = [` + quote(s.logHook("pre global")) + `]
101_post_hooks = [` + quote(s.logHook("post global")) + `]
102
103["@cloud".backup]
104_pre_hooks = [` + quote(s.logHook("pre suffix")) + `]
105_post_hooks = [` + quote(s.logHook("post suffix")) + `]
106
107["home@".backup]
108_pre_hooks = [` + quote(s.logHook("pre prefix")) + `]
109_post_hooks = [` + quote(s.logHook("post prefix")) + `]
110
111["home@cloud".backup]
112_pre_hooks = [` + quote(s.logHook("pre full")) + `]
113_post_hooks = [` + quote(s.logHook("post full")) + `]
114`)
115}
116
117func (s *backupHooksSuite) TheBackupConfigurationIsResolved() {
118 cfg, err := config.Resolve(s.preset, "backup", nil)
119 if err != nil {
120 s.Fatalf("resolving config: %v", err)
121 }
122 s.resolved = cfg
123}
124
125func (s *backupHooksSuite) ThePrehooksAreOrderedFromLowestToHighestPrioritySection() {
126 want := []string{s.logHook("pre global"), s.logHook("pre suffix"), s.logHook("pre prefix"), s.logHook("pre full")}
127 if !reflect.DeepEqual(s.resolved.PreHooks, want) {
128 s.Fatalf("pre hook order mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want)
129 }
130}
131
132func (s *backupHooksSuite) ThePrehooksComeFromTheMostspecificSectionThatDefinesPrehooks() {
133 want := []string{s.logHook("pre full")}
134 if !reflect.DeepEqual(s.resolved.PreHooks, want) {
135 s.Fatalf("pre hook override mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want)
136 }
137}
138
139func (s *backupHooksSuite) ThePosthooksAreOrderedFromLowestToHighestPrioritySection() {
140 want := []string{s.logHook("post global"), s.logHook("post suffix"), s.logHook("post prefix"), s.logHook("post full")}
141 if !reflect.DeepEqual(s.resolved.PostHooks, want) {
142 s.Fatalf("post hook order mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want)
143 }
144}
145
146func (s *backupHooksSuite) ThePosthooksComeFromTheMostspecificSectionThatDefinesPosthooks() {
147 want := []string{s.logHook("post full")}
148 if !reflect.DeepEqual(s.resolved.PostHooks, want) {
149 s.Fatalf("post hook override mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want)
150 }
151}
152
153func (s *backupHooksSuite) TheHooksAreNotPassedToResticAsFlags() {
154 for _, f := range s.resolved.Flags {
155 if strings.Contains(f.Name, "hook") {
156 s.Fatalf("hook leaked into restic flags: %#v", f)
157 }
158 }
159}
160
161func (s *backupHooksSuite) BackupHooksAreConfiguredInALessspecificMatchingSection() {
162 s.preset = "home"
163 s.writeRawConfig(`
164[global.backup]
165_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `]
166_pre_hooks = [` + quote(s.logHook("pre inherited")) + `]
167_post_hooks = [` + quote(s.logHook("post inherited")) + `]
168
169[home.backup]
170tag = "specific"
171`)
172}
173
174func (s *backupHooksSuite) TheMostspecificMatchingSectionOmitsHookKeys() {}
175
176func (s *backupHooksSuite) TheInheritedPrehooksAreUsed() {
177 want := []string{s.logHook("pre inherited")}
178 if !reflect.DeepEqual(s.resolved.PreHooks, want) {
179 s.Fatalf("inherited pre hooks mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want)
180 }
181}
182
183func (s *backupHooksSuite) TheInheritedPosthooksAreUsed() {
184 want := []string{s.logHook("post inherited")}
185 if !reflect.DeepEqual(s.resolved.PostHooks, want) {
186 s.Fatalf("inherited post hooks mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want)
187 }
188}
189
190func (s *backupHooksSuite) TheMostspecificMatchingSectionClearsHookKeys() {
191 s.writeRawConfig(`
192[global.backup]
193_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `]
194_pre_hooks = [` + quote(s.logHook("pre inherited")) + `]
195_post_hooks = [` + quote(s.logHook("post inherited")) + `]
196
197[home.backup]
198_pre_hooks = []
199_post_hooks = []
200`)
201}
202
203func (s *backupHooksSuite) NoPrehooksAreConfigured() {
204 if len(s.resolved.PreHooks) != 0 {
205 s.Fatalf("expected no pre-hooks, got %#v", s.resolved.PreHooks)
206 }
207}
208
209func (s *backupHooksSuite) NoPosthooksAreConfigured() {
210 if len(s.resolved.PostHooks) != 0 {
211 s.Fatalf("expected no post-hooks, got %#v", s.resolved.PostHooks)
212 }
213}
214
215func (s *backupHooksSuite) TheBackupConfigurationIncludesPrehooks() {
216 s.writeConfig("home", []string{s.logHook("pre one"), s.logHook("pre two")}, nil)
217}
218
219func (s *backupHooksSuite) KeldRunsTheBackup() {
220 flagPreset = s.preset
221 flagShowCmd = false
222 flagConfigFile = s.configPath
223 mustSetenv(s, "KELD_TEST_RESTIC_EXIT", strconv.Itoa(s.resticCode))
224
225 var err error
226 s.runStdout, s.runStderr, err = captureOutput(s, func() error {
227 return runCommand("backup", lookupSubcommandForHooks(s, "backup"), nil, nil, nil)
228 })
229 s.runErr = err
230}
231
232func (s *backupHooksSuite) EachPrehookRunsBeforeResticBackup() {
233 lines := s.logLines()
234 want := []string{"pre one", "pre two", "restic backup"}
235 for i, prefix := range want {
236 if len(lines) <= i || !strings.HasPrefix(lines[i], prefix) {
237 s.Fatalf("event %d mismatch: got log %#v, want prefix %q", i, lines, prefix)
238 }
239 }
240}
241
242func (s *backupHooksSuite) ResticBackupIsAttempted() {
243 if !s.logContains("restic backup") {
244 s.Fatalf("restic backup was not attempted; log: %#v", s.logLines())
245 }
246}
247
248func (s *backupHooksSuite) TheBackupConfigurationIncludesAFailingPrehook() {
249 s.writeConfig("home", []string{s.logHook("pre fail") + "; exit 42"}, []string{s.logHook("post should-not-run")})
250}
251
252func (s *backupHooksSuite) ResticBackupIsNotAttempted() {
253 if s.logContains("restic backup") {
254 s.Fatalf("restic backup was attempted; log: %#v", s.logLines())
255 }
256}
257
258func (s *backupHooksSuite) PosthooksAreNotRun() {
259 if s.logContains("post") {
260 s.Fatalf("post-hook ran unexpectedly; log: %#v", s.logLines())
261 }
262}
263
264func (s *backupHooksSuite) KeldReportsThePrehookFailure() {
265 if s.runErr == nil || !strings.Contains(s.runErr.Error(), "pre-hook") {
266 s.Fatalf("expected pre-hook failure, got %v", s.runErr)
267 }
268}
269
270func (s *backupHooksSuite) TheBackupConfigurationIncludesPosthooks() {
271 s.writeConfig("home", nil, []string{s.postEnvHook("post")})
272}
273
274func (s *backupHooksSuite) ResticBackupExitsWithCode(a int64) {
275 s.resticCode = int(a)
276}
277
278func (s *backupHooksSuite) EachPosthookRunsAfterResticBackup() {
279 lines := s.logLines()
280 if len(lines) < 2 || !strings.HasPrefix(lines[0], "restic backup") || !strings.HasPrefix(lines[1], "post") {
281 s.Fatalf("post-hook did not run after restic; log: %#v", lines)
282 }
283}
284
285func (s *backupHooksSuite) EachPosthookReceivesKeldresticexitcodeSetTo(a string) {
286 if !s.logContains("code=" + a) {
287 s.Fatalf("post-hook did not receive exit code %q; log: %#v", a, s.logLines())
288 }
289}
290
291func (s *backupHooksSuite) EachPosthookReceivesKeldresticstatusSetTo(a string) {
292 if !s.logContains("status=" + a) {
293 s.Fatalf("post-hook did not receive status %q; log: %#v", a, s.logLines())
294 }
295}
296
297func (s *backupHooksSuite) KeldExitsWithCode(a int64) {
298 code := int(a)
299 if code == 0 {
300 if s.runErr != nil {
301 s.Fatalf("expected successful exit, got %v", s.runErr)
302 }
303 return
304 }
305
306 var exitErr interface{ ExitCode() int }
307 if !errors.As(s.runErr, &exitErr) {
308 s.Fatalf("expected exit error with code %d, got %T: %v", code, s.runErr, s.runErr)
309 }
310 if got := exitErr.ExitCode(); got != code {
311 s.Fatalf("exit code mismatch: got %d, want %d", got, code)
312 }
313}
314
315func (s *backupHooksSuite) TheBackupConfigurationIncludesAFailingPosthook() {
316 s.writeConfig("home", nil, []string{s.logHook("post fail") + "; exit 44"})
317}
318
319func (s *backupHooksSuite) KeldReportsThePosthookFailure() {
320 if !strings.Contains(s.runStderr, "post-hook") {
321 s.Fatalf("expected post-hook failure on stderr, got stdout=%q stderr=%q err=%v", s.runStdout, s.runStderr, s.runErr)
322 }
323}
324
325func (s *backupHooksSuite) TheBackupConfigurationIncludesPrehooksAndPosthooks() {
326 s.writeConfig("home", []string{s.logHook("pre preview")}, []string{s.logHook("post preview")})
327}
328
329func (s *backupHooksSuite) ResticBackupFailsToStart() {
330 s.writeConfig("home", []string{s.logHook("pre preview")}, []string{s.postEnvHook("post preview")})
331 fakeRestic := os.Getenv("KELD_EXECUTABLE")
332 if err := os.WriteFile(fakeRestic, []byte("#!/definitely/missing/keld-restic-interpreter\n"), 0o755); err != nil {
333 s.Fatalf("rewriting fake restic: %v", err)
334 }
335}
336
337func (s *backupHooksSuite) EachPosthookRunsAfterTheFailedResticStart() {
338 lines := s.logLines()
339 if len(lines) < 2 || !strings.HasPrefix(lines[0], "pre preview") || !strings.HasPrefix(lines[1], "post preview") {
340 s.Fatalf("post-hook did not run after failed restic start; log: %#v", lines)
341 }
342}
343
344func (s *backupHooksSuite) EachPosthookReceivesNoKeldresticexitcode() {
345 for _, line := range s.logLines() {
346 if strings.HasPrefix(line, "post") && strings.Contains(line, "code=") && !strings.Contains(line, "code= ") {
347 s.Fatalf("post-hook unexpectedly received exit code; log: %#v", s.logLines())
348 }
349 }
350}
351
352func (s *backupHooksSuite) KeldReportsTheResticStartFailure() {
353 if s.runErr == nil || !strings.Contains(s.runErr.Error(), "starting restic") {
354 s.Fatalf("expected restic start failure, got %v", s.runErr)
355 }
356}
357
358func (s *backupHooksSuite) KeldShowsTheResolvedBackupCommand() {
359 flagPreset = s.preset
360 flagShowCmd = true
361 flagConfigFile = s.configPath
362
363 var err error
364 s.runStdout, s.runStderr, err = captureOutput(s, func() error {
365 return runCommand("backup", lookupSubcommandForHooks(s, "backup"), nil, nil, nil)
366 })
367 s.runErr = err
368}
369
370func (s *backupHooksSuite) TheOutputIncludesTheConfiguredPrehooks() {
371 if !strings.Contains(s.runStdout, "pre-hooks:") || !strings.Contains(s.runStdout, "pre preview") {
372 s.Fatalf("preview missing pre-hooks: %s", s.runStdout)
373 }
374}
375
376func (s *backupHooksSuite) TheOutputIncludesTheConfiguredPosthooks() {
377 if !strings.Contains(s.runStdout, "post-hooks:") || !strings.Contains(s.runStdout, "post preview") {
378 s.Fatalf("preview missing post-hooks: %s", s.runStdout)
379 }
380}
381
382func (s *backupHooksSuite) TheOutputDoesNotIncludeKeldresticexitcode() {
383 if strings.Contains(s.runStdout, "KELD_RESTIC_EXIT_CODE") {
384 s.Fatalf("preview included runtime exit code env: %s", s.runStdout)
385 }
386}
387
388func (s *backupHooksSuite) TheOutputDoesNotIncludeKeldresticstatus() {
389 if strings.Contains(s.runStdout, "KELD_RESTIC_STATUS") {
390 s.Fatalf("preview included runtime status env: %s", s.runStdout)
391 }
392}
393
394func (s *backupHooksSuite) writeConfig(preset string, preHooks, postHooks []string) {
395 s.preset = preset
396 var b strings.Builder
397 b.WriteString("[" + quote(preset) + "]\n")
398 b.WriteString("tag = \"bdd\"\n\n")
399 b.WriteString("[" + quote(preset) + ".backup]\n")
400 b.WriteString("_arguments = [" + quote(filepath.Join(s.tmpDir, "source")) + "]\n")
401 if len(preHooks) > 0 {
402 b.WriteString("_pre_hooks = [" + quotedList(preHooks) + "]\n")
403 }
404 if len(postHooks) > 0 {
405 b.WriteString("_post_hooks = [" + quotedList(postHooks) + "]\n")
406 }
407 s.writeRawConfig(b.String())
408}
409
410func (s *backupHooksSuite) writeRawConfig(toml string) {
411 if err := os.WriteFile(s.configPath, []byte(toml), 0o600); err != nil {
412 s.Fatalf("writing config: %v", err)
413 }
414 flagConfigFile = s.configPath
415 mustSetenv(s, "KELD_CONFIG_FILE", s.configPath)
416}
417
418func (s *backupHooksSuite) logHook(message string) string {
419 return "printf " + quote(message+"\n") + " >> \"$KELD_TEST_LOG\""
420}
421
422func (s *backupHooksSuite) postEnvHook(prefix string) string {
423 return "printf " + quote(prefix+" code=%s status=%s\n") + " \"$KELD_RESTIC_EXIT_CODE\" \"$KELD_RESTIC_STATUS\" >> \"$KELD_TEST_LOG\""
424}
425
426func (s *backupHooksSuite) logLines() []string {
427 data, err := os.ReadFile(s.logPath)
428 if errors.Is(err, os.ErrNotExist) {
429 return nil
430 }
431 if err != nil {
432 s.Fatalf("reading log: %v", err)
433 }
434 return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
435}
436
437func (s *backupHooksSuite) logContains(needle string) bool {
438 for _, line := range s.logLines() {
439 if strings.Contains(line, needle) {
440 return true
441 }
442 }
443 return false
444}
445
446func quote(s string) string { return strconv.Quote(s) }
447
448func quotedList(values []string) string {
449 quoted := make([]string, len(values))
450 for i, value := range values {
451 quoted[i] = quote(value)
452 }
453 return strings.Join(quoted, ", ")
454}
455
456func mustSetenv(s *backupHooksSuite, key, value string) {
457 if err := os.Setenv(key, value); err != nil {
458 s.Fatalf("setting %s: %v", key, err)
459 }
460}
461
462func restoreEnv(key, value string) {
463 if value == "" {
464 _ = os.Unsetenv(key)
465 return
466 }
467 _ = os.Setenv(key, value)
468}
469
470func lookupSubcommandForHooks(s *backupHooksSuite, name string) *cobra.Command {
471 s.Helper()
472 for _, cmd := range rootCmd.Commands() {
473 if cmd.Name() == name {
474 return cmd
475 }
476 }
477 s.Fatalf("subcommand %q not found", name)
478 return nil
479}
480
481func captureOutput(s *backupHooksSuite, run func() error) (string, string, error) {
482 s.Helper()
483
484 oldStdout := os.Stdout
485 oldStderr := os.Stderr
486 stdoutReader, stdoutWriter, err := os.Pipe()
487 if err != nil {
488 s.Fatalf("creating stdout pipe: %v", err)
489 }
490 stderrReader, stderrWriter, err := os.Pipe()
491 if err != nil {
492 s.Fatalf("creating stderr pipe: %v", err)
493 }
494 os.Stdout = stdoutWriter
495 os.Stderr = stderrWriter
496
497 var stdoutBuf, stderrBuf bytes.Buffer
498 var wg sync.WaitGroup
499 var stdoutErr, stderrErr error
500 wg.Add(2)
501 go func() {
502 defer wg.Done()
503 _, stdoutErr = io.Copy(&stdoutBuf, stdoutReader)
504 }()
505 go func() {
506 defer wg.Done()
507 _, stderrErr = io.Copy(&stderrBuf, stderrReader)
508 }()
509
510 runErr := run()
511 _ = stdoutWriter.Close()
512 _ = stderrWriter.Close()
513 os.Stdout = oldStdout
514 os.Stderr = oldStderr
515 wg.Wait()
516
517 if stdoutErr != nil {
518 s.Fatalf("reading stdout: %v", stdoutErr)
519 }
520 if stderrErr != nil {
521 s.Fatalf("reading stderr: %v", stderrErr)
522 }
523 _ = stdoutReader.Close()
524 _ = stderrReader.Close()
525
526 return stdoutBuf.String(), stderrBuf.String(), runErr
527}