backup_hooks_feature_test.go

  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}