mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
366 lines
12 KiB
Go
366 lines
12 KiB
Go
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
|
|
// "<Use> version <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)
|
|
}
|
|
})
|
|
}
|
|
}
|