diff --git a/integration/aliases_test.go b/integration/aliases_test.go new file mode 100644 index 0000000..ca4960d --- /dev/null +++ b/integration/aliases_test.go @@ -0,0 +1,169 @@ +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExecutableAliases(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"exact alias created", testAliasExact}, + {"wrong payload decides", testAliasWrong}, + {"occupied path decides", testAliasOccupied}, + {"dangling exact stays", testAliasDangling}, + {"file to alias transition", testAliasFileToAlias}, + {"alias to file transition", testAliasAliasToFile}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// writeRoutes writes one routes file declaring the tool alias. +func writeRoutes(t *testing.T, env execEnv, source string) { + t.Helper() + writeFile(t, filepath.Join(env.repo, "_routes.toml"), []byte(source)) +} + +// toolRoutes is the standard fixture route declaration. +const toolRoutes = `version = 1 + +[symlinks.all] +".config/tool" = ["bin/tool"] +` + +// readLink reads one HOME-relative symlink payload. +func readLink(t *testing.T, env execEnv, relative string) string { + t.Helper() + content, err := os.Readlink(filepath.Join(env.home, filepath.FromSlash(relative))) + if err != nil { + t.Fatalf("readlink %s: %v", relative, err) + } + return content +} + +func testAliasExact(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + writeRoutes(t, env, toolRoutes) + env.source(t, ".config/tool", "content") + if err := os.MkdirAll(filepath.Join(env.home, "bin"), 0o700); err != nil { + t.Fatal(err) + } + result := env.run(t, nil, "apply") + if result.Code != 0 { + t.Fatalf("apply: code=%d stderr=%q", result.Code, result.Stderr) + } + if payload := readLink(t, env, "bin/tool"); payload != "../.config/tool" { + t.Fatalf("payload = %q, want ../.config/tool", payload) + } +} + +func testAliasWrong(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + writeRoutes(t, env, toolRoutes) + env.source(t, ".config/tool", "content") + if err := os.MkdirAll(filepath.Join(env.home, "bin"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink("elsewhere", filepath.Join(env.home, "bin", "tool")); err != nil { + t.Fatal(err) + } + result := env.runPty(t, []string{"overwrite"}, "apply") + if result.Code != 0 { + t.Fatalf("apply: code=%d stderr=%q", result.Code, result.Stderr) + } + if payload := readLink(t, env, "bin/tool"); payload != "../.config/tool" { + t.Fatalf("payload = %q, want the corrected link", payload) + } +} + +func testAliasOccupied(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + writeRoutes(t, env, toolRoutes) + env.source(t, ".config/tool", "content") + if err := os.MkdirAll(filepath.Join(env.home, "bin"), 0o700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(env.home, "bin", "tool"), []byte("intruder")) + result := env.runPty(t, []string{"overwrite"}, "apply") + if result.Code != 0 { + t.Fatalf("apply: code=%d stderr=%q", result.Code, result.Stderr) + } + if payload := readLink(t, env, "bin/tool"); payload != "../.config/tool" { + t.Fatalf("payload = %q, want the replaced link", payload) + } +} + +func testAliasDangling(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + writeRoutes(t, env, toolRoutes) + env.source(t, ".config/tool", "content") + if err := os.MkdirAll(filepath.Join(env.home, "bin"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink("../.config/tool", filepath.Join(env.home, "bin", "tool")); err != nil { + t.Fatal(err) + } + result := env.run(t, nil, "apply") + if result.Code != 0 { + t.Fatalf("apply: code=%d stderr=%q", result.Code, result.Stderr) + } + if payload := readLink(t, env, "bin/tool"); payload != "../.config/tool" { + t.Fatalf("payload = %q, want the exact link kept", payload) + } +} + +func testAliasFileToAlias(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + env.source(t, ".config/tool", "content") + if result := env.run(t, nil, "apply"); result.Code != 0 { + t.Fatalf("file apply: %+v", result) + } + if string(env.target(t, ".config/tool")) != "content" { + t.Fatal("the file must deploy first") + } + writeRoutes(t, env, toolRoutes) + if err := os.MkdirAll(filepath.Join(env.home, "bin"), 0o700); err != nil { + t.Fatal(err) + } + result := env.run(t, nil, "apply") + if result.Code != 0 { + t.Fatalf("transition apply: code=%d stderr=%q", result.Code, result.Stderr) + } + if payload := readLink(t, env, "bin/tool"); payload != "../.config/tool" { + t.Fatalf("payload = %q, want the alias after the transition", payload) + } +} + +func testAliasAliasToFile(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + writeRoutes(t, env, toolRoutes) + env.source(t, ".config/tool", "content") + if err := os.MkdirAll(filepath.Join(env.home, "bin"), 0o700); err != nil { + t.Fatal(err) + } + if result := env.run(t, nil, "apply"); result.Code != 0 { + t.Fatalf("alias apply: %+v", result) + } + _ = strings.TrimSpace("") + env.source(t, "x/tool", "content") + writeRoutes(t, env, "version = 1\n\n[symlinks.all]\n") + result := env.run(t, nil, "apply") + if result.Code != 0 { + t.Fatalf("transition apply: code=%d stderr=%q", result.Code, result.Stderr) + } + if string(env.target(t, "tool")) != "content" { + t.Fatal("the file must replace the alias after the transition") + } +} diff --git a/integration/failures_test.go b/integration/failures_test.go new file mode 100644 index 0000000..3baa2f6 --- /dev/null +++ b/integration/failures_test.go @@ -0,0 +1,86 @@ +package integration + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExecutableFailures(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"pre-rename keeps the old target", testFailuresPreRename}, + {"later item preserves earlier", testFailuresLaterItem}, + {"retry recovers by equality", testFailuresRecovery}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testFailuresPreRename(t *testing.T) { + race := NewRaceFixture(t) + race.initRepository(t) + race.source(t, ".config/app", "v1") + if result := race.run(t, nil, "apply"); result.Code != 0 { + t.Fatalf("first apply: %+v", result) + } + race.source(t, ".config/app", "v2") + race.blockTargetParent(t, ".config/app") + result := race.run(t, nil, "apply") + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for a blocked rename", result.Code) + } + if string(race.target(t, ".config/app")) != "v1" { + t.Fatal("a pre-rename failure must keep the old destination") + } +} + +func testFailuresLaterItem(t *testing.T) { + race := NewRaceFixture(t) + race.initRepository(t) + race.source(t, ".config/a", "a") + race.source(t, "x/bin/b", "b") + if result := race.run(t, nil, "apply"); result.Code != 0 { + t.Fatalf("first apply: %+v", result) + } + race.source(t, ".config/a", "a2") + race.source(t, "x/bin/b", "b2") + if err := os.Chmod(filepath.Join(race.home, "bin"), 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(filepath.Join(race.home, "bin"), 0o700) }) + result := race.run(t, nil, "apply") + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for a later-item failure", result.Code) + } + if string(race.target(t, ".config/a")) != "a2" { + t.Fatal("the earlier item must remain accurate") + } +} + +func testFailuresRecovery(t *testing.T) { + race := NewRaceFixture(t) + race.initRepository(t) + race.source(t, ".config/app", "v1") + if result := race.run(t, nil, "apply"); result.Code != 0 { + t.Fatalf("first apply: %+v", result) + } + race.source(t, ".config/app", "v2") + race.blockTargetParent(t, ".config/app") + if result := race.run(t, nil, "apply"); result.Code != 1 { + t.Fatalf("blocked apply: %+v", result) + } + if err := os.Chmod(filepath.Join(race.home, ".config"), 0o700); err != nil { + t.Fatal(err) + } + result := race.run(t, nil, "apply") + if result.Code != 0 { + t.Fatalf("recovery apply: %+v", result) + } + if string(race.target(t, ".config/app")) != "v2" { + t.Fatal("the retry must converge the target") + } +} diff --git a/integration/path_safety_test.go b/integration/path_safety_test.go new file mode 100644 index 0000000..c97119e --- /dev/null +++ b/integration/path_safety_test.go @@ -0,0 +1,84 @@ +package integration + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExecutablePathSafety(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"symlinked parent rejected", testPathSymlinkParent}, + {"blocking ancestor rejected", testPathBlockingAncestor}, + {"case collision rejected", testPathCaseCollision}, + {"parent child collision", testPathParentChild}, + {"source target identity", testPathIdentity}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testPathSymlinkParent(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + env.source(t, ".config/app", "v1") + if err := os.MkdirAll(filepath.Join(env.home, ".config"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(env.home, ".config")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(env.repo, ".config"), filepath.Join(env.home, ".config")); err != nil { + t.Fatal(err) + } + result := env.run(t, nil, "apply") + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for a symlinked target parent", result.Code) + } +} + +func testPathBlockingAncestor(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + env.source(t, "x/a/b", "v1") + writeFile(t, filepath.Join(env.home, "a"), []byte("a file")) + result := env.run(t, nil, "apply") + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for a blocking ancestor", result.Code) + } +} + +func testPathCaseCollision(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + env.source(t, ".config/app", "v1") + env.source(t, ".config/App", "v2") + result := env.run(t, nil, "validate") + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for a case-colliding repository", result.Code) + } +} + +func testPathParentChild(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + env.source(t, "a", "v1") + env.source(t, "x/a/b", "v2") + result := env.run(t, nil, "validate") + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for a parent-child collision", result.Code) + } +} + +func testPathIdentity(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + result := env.run(t, nil, "init", env.home) + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for a repository identical to HOME", result.Code) + } +} diff --git a/integration/race_fixture_test.go b/integration/race_fixture_test.go new file mode 100644 index 0000000..b444cd9 --- /dev/null +++ b/integration/race_fixture_test.go @@ -0,0 +1,215 @@ +package integration + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" + "unsafe" +) + +// RaceFixture extends the executable environment with deterministic +// filesystem and state failure injections for black-box precondition and +// partial-commit tests. No production file is patched. +type RaceFixture struct { + execEnv +} + +// NewRaceFixture builds one race environment over the executable. +func NewRaceFixture(t *testing.T) RaceFixture { + t.Helper() + return RaceFixture{execEnv: newExecEnv(t)} +} + +// stateDir returns the cattery state directory. +func (race RaceFixture) stateDir() string { + return filepath.Join(race.home, ".local", "state", "cattery") +} + +// fOfdSetlk is the linux open-file-description write lock command. +const fOfdSetlk = 37 + +// lockStateWrites takes exclusive OFD write locks on the database and its +// WAL-index so a concurrent baseline commit times out and fails. The +// locks are released at cleanup. +func (race RaceFixture) lockStateWrites(t *testing.T) { + t.Helper() + files := race.lockFiles(t) + t.Cleanup(func() { + unlock := syscall.Flock_t{Type: syscall.F_UNLCK, Whence: 0, Start: 0, Len: 0} + for _, file := range files { + _ = fcntlLock(file, unlock) + _ = file.Close() + } + }) +} + +// lockFiles opens and locks every state file. +func (race RaceFixture) lockFiles(t *testing.T) []*os.File { + t.Helper() + var files []*os.File + lock := syscall.Flock_t{Type: syscall.F_WRLCK, Whence: 0, Start: 0, Len: 0} + for _, name := range []string{"state.db", "state.db-shm", "state.db-wal"} { + file, err := os.OpenFile(filepath.Join(race.stateDir(), name), os.O_RDWR, 0) + if err != nil { + closeAll(files) + t.Fatalf("open %s: %v", name, err) + } + if err := fcntlLock(file, lock); err != nil { + _ = file.Close() + closeAll(files) + t.Fatalf("lock %s: %v", name, err) + } + files = append(files, file) + } + return files +} + +// closeAll closes every held file. +func closeAll(files []*os.File) { + for _, file := range files { + _ = file.Close() + } +} + +// fcntlLock applies one OFD lock command to the file descriptor. +func fcntlLock(file *os.File, lock syscall.Flock_t) error { + _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, file.Fd(), fOfdSetlk, uintptr(unsafe.Pointer(&lock))) + if errno != 0 { + return errno + } + return nil +} + +// blockTargetParent makes one target's parent directory read-only so the +// replacement rename fails before publication. +func (race RaceFixture) blockTargetParent(t *testing.T, relative string) { + t.Helper() + parent := filepath.Dir(filepath.Join(race.home, filepath.FromSlash(relative))) + if err := os.Chmod(parent, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(parent, 0o700) }) +} + +// replaceDatabaseWithDirectory replaces the state database with a +// directory so the next open fails deterministically. +func (race RaceFixture) replaceDatabaseWithDirectory(t *testing.T) { + t.Helper() + database := filepath.Join(race.stateDir(), "state.db") + if err := os.Remove(database); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(database, 0o700); err != nil { + t.Fatal(err) + } +} + +func TestRaceFixture(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"barriers fire once", testRaceBarriers}, + {"cleanup restores", testRaceCleanup}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testRaceBarriers(t *testing.T) { + race := NewRaceFixture(t) + race.initRepository(t) + race.source(t, ".config/app", "v1") + if result := race.run(t, nil, "apply"); result.Code != 0 { + t.Fatalf("first apply: %+v", result) + } + race.source(t, ".config/app", "v2") + race.blockTargetParent(t, ".config/app") + result := race.run(t, nil, "apply") + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for a blocked target parent", result.Code) + } + if string(race.target(t, ".config/app")) != "v1" { + t.Fatal("a pre-rename failure must preserve the old target") + } + race.replaceDatabaseWithDirectory(t) + result = race.run(t, nil, "apply") + if result.Code != 1 { + t.Fatalf("code = %d, want 1 for a replaced database", result.Code) + } +} + +// processHandle tracks one asynchronously started invocation. +type processHandle struct { + done chan ProcessResult +} + +// start launches the binary and returns a handle. +func (race RaceFixture) start(t *testing.T, args ...string) processHandle { + t.Helper() + command := exec.Command(race.fixture.Binary, args...) + command.Env = []string{ + "HOME=" + race.home, + "XDG_STATE_HOME=" + filepath.Join(race.home, ".local", "state"), + "PATH=" + os.Getenv("PATH"), + } + command.Dir = race.home + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + command.Stdout = stdout + command.Stderr = stderr + done := make(chan ProcessResult, 1) + if err := command.Start(); err != nil { + t.Fatalf("start: %v", err) + } + go func() { + err := command.Wait() + code := 0 + if err != nil { + code = exitCodeOf(err) + } + done <- ProcessResult{Stdout: stdout.String(), Stderr: stderr.String(), Code: code} + }() + return processHandle{done: done} +} + +// finish waits for the process outcome. +func (handle processHandle) finish(t *testing.T) ProcessResult { + t.Helper() + select { + case result := <-handle.done: + return result + case <-time.After(30 * time.Second): + t.Fatal("process did not finish") + return ProcessResult{} + } +} + +// awaitTarget polls until the target carries the given content. +func (race RaceFixture) awaitTarget(t *testing.T, relative, want string) { + t.Helper() + path := filepath.Join(race.home, filepath.FromSlash(relative)) + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + content, err := os.ReadFile(path) + if err == nil && string(content) == want { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("target %s never carried %q", relative, want) +} + +func testRaceCleanup(t *testing.T) { + race := NewRaceFixture(t) + race.initRepository(t) + race.source(t, ".config/app", "v1") + if result := race.run(t, nil, "apply"); result.Code != 0 { + t.Fatalf("apply: %+v", result) + } +} diff --git a/integration/signals_test.go b/integration/signals_test.go new file mode 100644 index 0000000..41caa08 --- /dev/null +++ b/integration/signals_test.go @@ -0,0 +1,126 @@ +package integration + +import ( + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestExecutableSignals(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"interrupt is 130", testSignalsInterrupt}, + {"terminate is 143", testSignalsTerminate}, + {"descendants terminate", testSignalsDescendants}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// slowHook writes one hook that signals readiness and sleeps. +func slowHook(t *testing.T, env execEnv, phase string) { + t.Helper() + content := "#!/bin/sh\ntouch $CATTERY_HOME/hook-ready\ntrap '' TERM\necho waiting\nsleep 30\n" + path := filepath.Join(env.repo, "_hooks", phase, phase+".sh") + writeFile(t, path, []byte(content)) + if err := os.Chmod(path, 0o755); err != nil { + t.Fatal(err) + } +} + +// startApply launches one apply and waits for the hook readiness marker. +func startApply(t *testing.T, env execEnv) *exec.Cmd { + t.Helper() + command := exec.Command(env.fixture.Binary, "apply") + command.Env = []string{ + "HOME=" + env.home, + "XDG_STATE_HOME=" + filepath.Join(env.home, ".local", "state"), + "PATH=" + os.Getenv("PATH"), + } + command.Dir = env.home + if err := command.Start(); err != nil { + t.Fatalf("start: %v", err) + } + marker := filepath.Join(env.home, "hook-ready") + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(marker); err == nil { + return command + } + time.Sleep(20 * time.Millisecond) + } + _ = command.Process.Kill() + t.Fatal("hook never became ready") + return nil +} + +// finishSignal waits for the process and returns its exit code. +func finishSignal(t *testing.T, command *exec.Cmd) int { + t.Helper() + err := command.Wait() + if err == nil { + return 0 + } + if exit, ok := err.(*exec.ExitError); ok { + return exit.ExitCode() + } + t.Fatalf("wait: %v", err) + return -1 +} + +func testSignalsInterrupt(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + env.source(t, ".config/app", "v1") + slowHook(t, env, "before") + command := startApply(t, env) + if err := command.Process.Signal(syscall.SIGINT); err != nil { + t.Fatal(err) + } + if code := finishSignal(t, command); code != 130 { + t.Fatalf("code = %d, want 130", code) + } +} + +func testSignalsTerminate(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + env.source(t, ".config/app", "v1") + slowHook(t, env, "before") + command := startApply(t, env) + if err := command.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + if code := finishSignal(t, command); code != 143 { + t.Fatalf("code = %d, want 143", code) + } +} + +func testSignalsDescendants(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + env.source(t, ".config/app", "v1") + child := filepath.Join(env.repo, "_hooks", "before", "before.sh") + content := "#!/bin/sh\n(sh -c 'sleep 30; touch $CATTERY_HOME/descendant-live') &\ntouch $CATTERY_HOME/hook-ready\nsleep 30\n" + writeFile(t, child, []byte(content)) + if err := os.Chmod(child, 0o755); err != nil { + t.Fatal(err) + } + command := startApply(t, env) + if err := command.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + if code := finishSignal(t, command); code != 143 { + t.Fatalf("code = %d, want 143", code) + } + time.Sleep(500 * time.Millisecond) + if _, err := os.Stat(filepath.Join(env.home, "descendant-live")); err == nil { + t.Fatal("a descendant must be terminated with the hook group") + } +} diff --git a/internal/bootstrap/build_test.go b/internal/bootstrap/build_test.go index 8f6588f..7199137 100644 --- a/internal/bootstrap/build_test.go +++ b/internal/bootstrap/build_test.go @@ -2,6 +2,7 @@ package bootstrap import ( "bytes" + "context" "strings" "testing" @@ -49,7 +50,7 @@ func testBuildApplication(t *testing.T) { func testBuildVersion(t *testing.T) { application, stdout, _ := buildFixture(t) - if err := application.Execute([]string{"version"}); err != nil { + if err := application.Execute(context.Background(), []string{"version"}); err != nil { t.Fatalf("run: %v", err) } if !strings.Contains(stdout.String(), "cattery") { @@ -59,7 +60,7 @@ func testBuildVersion(t *testing.T) { func testBuildHelp(t *testing.T) { application, stdout, _ := buildFixture(t) - if err := application.Execute(nil); err != nil { + if err := application.Execute(context.Background(), nil); err != nil { t.Fatalf("run: %v", err) } if !strings.Contains(stdout.String(), "cattery") { @@ -69,7 +70,7 @@ func testBuildHelp(t *testing.T) { func testBuildParseFailure(t *testing.T) { application, _, stderr := buildFixture(t) - if err := application.Execute([]string{"--version"}); err == nil { + if err := application.Execute(context.Background(), []string{"--version"}); err == nil { t.Fatal("an unknown root flag must fail") } if stderr.Len() != 0 { diff --git a/internal/cli/execute.go b/internal/cli/execute.go index 8a9752e..6bdd9b0 100644 --- a/internal/cli/execute.go +++ b/internal/cli/execute.go @@ -13,7 +13,7 @@ import ( // Section 11.8 exit status. Numeric statuses exist only here and // os.Exit stays in the process entrypoint. func Execute(ctx context.Context, application *Application, args []string) int { - err := application.Execute(args) + err := application.Execute(ctx, args) if err == nil { return 0 } diff --git a/internal/cli/root.go b/internal/cli/root.go index 05aebaf..2ccd4d6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,6 +1,8 @@ package cli import ( + "context" + "github.com/alyraffauf/cattery/internal/failure" "github.com/spf13/cobra" ) @@ -59,13 +61,14 @@ func NewApplication(dependencies Dependencies, runtime Runtime) *Application { return &Application{root: root} } -// Execute runs the application exactly once over the given arguments; a -// second use is rejected. -func (a *Application) Execute(args []string) error { +// Execute runs the application exactly once over the given arguments and +// process context; a second use is rejected. +func (a *Application) Execute(ctx context.Context, args []string) error { if a.executed { return failure.New(failure.Operational, "cli: application already executed", nil) } a.executed = true + a.root.SetContext(ctx) a.root.SetArgs(args) return a.root.Execute() } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 1594f90..e9787bf 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "context" "strings" "testing" ) @@ -71,7 +72,7 @@ func zeroCalls(fakes *rootFakes) bool { func testRootHelp(t *testing.T) { application, fakes, stdout, _ := rootFixture(t) - if err := application.Execute(nil); err != nil { + if err := application.Execute(context.Background(), nil); err != nil { t.Fatalf("run: %v", err) } if !strings.Contains(stdout.String(), "cattery") { @@ -84,7 +85,7 @@ func testRootHelp(t *testing.T) { func testRootInventory(t *testing.T) { application, fakes, stdout, _ := rootFixture(t) - if err := application.Execute([]string{"--help"}); err != nil { + if err := application.Execute(context.Background(), []string{"--help"}); err != nil { t.Fatalf("run: %v", err) } for _, name := range []string{"init", "validate", "version", "status", "diff", "add", "apply"} { @@ -100,7 +101,7 @@ func testRootInventory(t *testing.T) { func testRootFlags(t *testing.T) { application, fakes, _, _ := rootFixture(t) fakes.status.result = statusResult() - if err := application.Execute([]string{"status", "apps", "-r", "repo", "tools"}); err != nil { + if err := application.Execute(context.Background(), []string{"status", "apps", "-r", "repo", "tools"}); err != nil { t.Fatalf("run: %v", err) } request := fakes.status.requests[0] @@ -114,7 +115,7 @@ func testRootFlags(t *testing.T) { func testRootUnknownVersion(t *testing.T) { application, fakes, _, _ := rootFixture(t) - if err := application.Execute([]string{"--version"}); err == nil { + if err := application.Execute(context.Background(), []string{"--version"}); err == nil { t.Fatal("--version must remain an unknown root flag") } if !zeroCalls(fakes) { @@ -124,7 +125,7 @@ func testRootUnknownVersion(t *testing.T) { func testRootZeroCalls(t *testing.T) { application, fakes, _, _ := rootFixture(t) - if err := application.Execute([]string{"nonsense"}); err == nil { + if err := application.Execute(context.Background(), []string{"nonsense"}); err == nil { t.Fatal("an unknown command must fail") } if !zeroCalls(fakes) {