package cli import ( "bytes" "errors" "os" "path/filepath" "strings" "testing" "time" "github.com/kardianos/service" ) // fakeService implements the parts of service.Service that rollback uses. Any other // method panics, which keeps accidental dependencies visible. type fakeService struct { service.Service stopErr error stopCalls int statuses []service.Status // consumed one per Status() call; the last repeats statusErr error onStopCall func() } func (f *fakeService) Stop() error { f.stopCalls++ if f.onStopCall != nil { f.onStopCall() } return f.stopErr } func (f *fakeService) Status() (service.Status, error) { if f.statusErr != nil { return service.StatusUnknown, f.statusErr } if len(f.statuses) == 0 { return service.StatusStopped, nil } st := f.statuses[0] if len(f.statuses) > 1 { f.statuses = f.statuses[1:] } return st, nil } func TestStopServiceAndWait(t *testing.T) { tests := []struct { name string svc *fakeService timeout time.Duration wantErr bool }{ { name: "stops after a few polls", svc: &fakeService{statuses: []service.Status{service.StatusRunning, service.StatusRunning, service.StatusStopped}}, timeout: 5 * time.Second, }, { name: "already stopped", svc: &fakeService{statuses: []service.Status{service.StatusStopped}}, timeout: 5 * time.Second, }, { // A stop request that errors is not fatal on its own: the process may be // exiting anyway, so the status poll decides. name: "stop errors but service is stopped", svc: &fakeService{stopErr: errors.New("already stopped"), statuses: []service.Status{service.StatusStopped}}, timeout: 5 * time.Second, }, { name: "not installed", svc: &fakeService{statusErr: service.ErrNotInstalled}, timeout: 5 * time.Second, }, { // The process never exits. Rollback must be told so, because modifying a // running executable is what produced "Access is denied". name: "never stops", svc: &fakeService{statuses: []service.Status{service.StatusRunning}}, timeout: time.Millisecond, wantErr: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := stopServiceAndWait(tc.svc, tc.timeout) if tc.wantErr && err == nil { t.Fatal("expected an error, got nil") } if !tc.wantErr && err != nil { t.Fatalf("unexpected error: %v", err) } if tc.svc.stopCalls != 1 { t.Errorf("Stop() called %d times, want 1", tc.svc.stopCalls) } }) } } func TestRemoveBinaryWithRetry(t *testing.T) { t.Run("removes an existing file", func(t *testing.T) { path := filepath.Join(t.TempDir(), "ctrld") if err := os.WriteFile(path, []byte("binary"), 0o755); err != nil { t.Fatal(err) } if err := removeBinaryWithRetry(path, time.Second); err != nil { t.Fatalf("unexpected error: %v", err) } if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Errorf("file still exists after removal: %v", err) } }) t.Run("missing file is not an error", func(t *testing.T) { path := filepath.Join(t.TempDir(), "absent") if err := removeBinaryWithRetry(path, time.Second); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("gives up and reports when the path cannot be removed", func(t *testing.T) { // A non-empty directory stands in for a locked executable: os.Remove keeps // failing, so the retry loop must surface the error rather than hang. dir := filepath.Join(t.TempDir(), "locked") if err := os.Mkdir(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "child"), nil, 0o644); err != nil { t.Fatal(err) } if err := removeBinaryWithRetry(dir, time.Millisecond); err == nil { t.Fatal("expected an error for a path that cannot be removed") } }) } func TestBinaryVersion(t *testing.T) { t.Run("reports the version", func(t *testing.T) { t.Setenv(envFakeVersionOutput, cliName+" version dev-94fbd3f") got, err := binaryVersion(os.Args[0]) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != "dev-94fbd3f" { t.Errorf("binaryVersion() = %q, want %q", got, "dev-94fbd3f") } }) t.Run("rejects a binary that prints no version", func(t *testing.T) { // The incident's ctrld.exe_previous: the file exists and runs, but produces no // version output. Restoring it would have replaced a hung service with one // that cannot start at all. t.Setenv(envFakeVersionOutput, envFakeVersionSilent) if _, err := binaryVersion(os.Args[0]); err == nil { t.Fatal("expected an error for a binary with no version output") } }) t.Run("rejects a missing binary", func(t *testing.T) { if _, err := binaryVersion(filepath.Join(t.TempDir(), "absent")); err == nil { t.Fatal("expected an error for a missing binary") } }) } // stubBinaryVersion makes the version probe report ver for any path, so a rollback // test does not have to stage a runnable executable. // // Staging one is not portable: oldBin is bin+"_previous", so a fixture named "ctrld" // yields the extension-less "ctrld_previous", which Windows refuses to execute // ("executable file not found in %PATH%"), and a symlink to the test binary needs a // privilege Windows does not grant by default. The probe itself is covered against the // real test binary in TestBinaryVersion; these tests are about rollback's ordering. func stubBinaryVersion(t *testing.T, ver string, err error) { t.Helper() prev := binaryVersionFn binaryVersionFn = func(string) (string, error) { return ver, err } t.Cleanup(func() { binaryVersionFn = prev }) } func TestRollbackToPreviousBinaryStopsBeforeTouchingTheBinary(t *testing.T) { dir := t.TempDir() bin := filepath.Join(dir, "ctrld") oldBin := bin + oldBinSuffix if err := os.WriteFile(bin, []byte("replacement"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(oldBin, []byte("previous"), 0o755); err != nil { t.Fatal(err) } stubBinaryVersion(t, "dev-a75d669", nil) // The invariant: when stop runs, the replacement's executable is still untouched. // Reversing these two is exactly the "Access is denied" defect. var stopped bool var binExistedAtStop bool stop := func() error { stopped = true _, err := os.Stat(bin) binExistedAtStop = err == nil return nil } restarted := false restart := func() bool { restarted = true; return true } if err := rollbackToPreviousBinary(bin, oldBin, stop, restart); err != nil { t.Fatalf("unexpected error: %v", err) } if !stopped { t.Error("rollback did not stop the service") } if !binExistedAtStop { t.Error("the binary was modified before the service was stopped") } if !restarted { t.Error("rollback did not restart the service") } if _, err := os.Stat(oldBin); !errors.Is(err, os.ErrNotExist) { t.Errorf("previous binary was not moved into place: %v", err) } if _, err := os.Stat(bin); err != nil { t.Errorf("restored binary is missing: %v", err) } } func TestRollbackToPreviousBinaryKeepsUnusablePrevious(t *testing.T) { dir := t.TempDir() bin := filepath.Join(dir, "ctrld") oldBin := bin + oldBinSuffix if err := os.WriteFile(bin, []byte("replacement"), 0o755); err != nil { t.Fatal(err) } // A previous binary that exists but does not report a version, as in the incident. if err := os.WriteFile(oldBin, []byte("not a working binary"), 0o755); err != nil { t.Fatal(err) } // Stubbed rather than left to the real probe: that would fail here for the right // reason on unix (not an executable) but the wrong one on Windows (the fixture's // name has no extension), so the assertion would not be about usability at all. stubBinaryVersion(t, "", errors.New("unexpected --version output")) stopped := false restarted := false err := rollbackToPreviousBinary(bin, oldBin, func() error { stopped = true; return nil }, func() bool { restarted = true; return true }, ) if err == nil { t.Fatal("expected an error when the previous binary is unusable") } if !stopped { t.Error("the service must still be stopped: a broken replacement holds enforcement") } if restarted { t.Error("must not restart the service with an unusable binary") } // Nothing was swapped, and the previous file is kept for inspection. if _, err := os.Stat(oldBin); err != nil { t.Errorf("unusable previous binary was not preserved: %v", err) } if _, err := os.Stat(bin); err != nil { t.Errorf("installed binary was removed despite having nothing to restore: %v", err) } } func TestRollbackToPreviousBinaryAbortsWhenStopFails(t *testing.T) { dir := t.TempDir() bin := filepath.Join(dir, "ctrld") oldBin := bin + oldBinSuffix for _, p := range []string{bin, oldBin} { if err := os.WriteFile(p, []byte("binary"), 0o755); err != nil { t.Fatal(err) } } stopErr := errors.New("service did not stop within 30s") err := rollbackToPreviousBinary(bin, oldBin, func() error { return stopErr }, func() bool { t.Error("must not restart after a failed stop"); return false }, ) if !errors.Is(err, stopErr) { t.Fatalf("error = %v, want %v", err, stopErr) } // The executable of a process that may still be running must be left alone. if _, err := os.Stat(bin); err != nil { t.Errorf("binary was modified even though the stop failed: %v", err) } } // TestVersionOutputParsesThroughRollbackProbe ties the "--version" output the root // command actually produces to the parser rollback reads it with. // // These are two halves of one contract that live in different files: Cobra renders // " version ", and binaryVersion cuts a prefix off it. Renaming the // client moved the first half; if the second half had kept its literal, every // upgrade would have logged "unknown version" and - the part that matters - // rollbackToPreviousBinary would have judged a perfectly good previous binary // "not usable" and left the host stopped with the broken one installed. // // The version output is taken from the real root command rather than assembled // here, so a future change to the name, the template, or the parser has to keep // them agreeing. func TestVersionOutputParsesThroughRollbackProbe(t *testing.T) { rootCmd := initCLI() rootCmd.SetVersionTemplate(rootCmd.VersionTemplate()) var out bytes.Buffer rootCmd.SetOut(&out) rootCmd.SetErr(&out) rootCmd.SetArgs([]string{"--version"}) if err := rootCmd.Execute(); err != nil { t.Fatalf("running --version: %v", err) } got := out.String() if strings.TrimSpace(got) == "" { t.Fatal("--version produced no output") } ver, ok := parseVersionOutput(got) if !ok { t.Fatalf("the version probe cannot parse the root command's own --version output %q; "+ "rollback would reject a working previous binary as unusable", strings.TrimSpace(got)) } if ver != appVersion { t.Errorf("parsed version = %q, want %q", ver, appVersion) } } // TestParseVersionOutput covers the shapes the probe must accept and reject. The // rejected ones are what a genuinely broken previous binary produces - the // incident's ctrld.exe_previous printed nothing at all - and rollback depends on // telling those apart from a healthy binary under a new name. func TestParseVersionOutput(t *testing.T) { tests := []struct { name string out string want string ok bool }{ {"current identity", cliName + " version v1.0.0", "v1.0.0", true}, {"trailing newline", cliName + " version v1.0.0\n", "v1.0.0", true}, {"dev build", cliName + " version dev-94fbd3f", "dev-94fbd3f", true}, // The pre-rename identity: a v1-line binary is not a valid rollback target // for this client, and must not be read as one. {"previous identity", "ctrld version v1.3.5", "", false}, {"no output", "", "", false}, {"unrelated output", "some other program", "", false}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() got, ok := parseVersionOutput(tc.out) if ok != tc.ok { t.Fatalf("parseVersionOutput(%q) ok = %v, want %v", tc.out, ok, tc.ok) } if got != tc.want { t.Errorf("parseVersionOutput(%q) = %q, want %q", tc.out, got, tc.want) } }) } }