mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 03:33:53 +00:00
b763b80680
agent_state_dir_lifecycle_test.go calls syscall.Mkfifo, which does not
exist on Windows, so scripts/installtests has failed to compile in the
Windows leg of Unified Agent Native Verification since 53267e149d and the
install.ps1 contract tests there have not run. Every test in the file
drives install.sh through bash and systemd, so tag the file unix-only,
matching the other lifecycle lab files. GOOS=windows go vet now passes.
526 lines
17 KiB
Go
526 lines
17 KiB
Go
//go:build unix
|
|
|
|
package installtests
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestInstallSHAgentIDRecoveryRejectsSymlinkFIFOAndOversizedState(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Unix descriptor-bound identity recovery")
|
|
}
|
|
binaryPath := buildLifecycleAgent(t)
|
|
root := t.TempDir()
|
|
validPath := filepath.Join(root, "valid-agent-id")
|
|
oversizedPath := filepath.Join(root, "oversized-agent-id")
|
|
symlinkPath := filepath.Join(root, "symlink-agent-id")
|
|
fifoPath := filepath.Join(root, "fifo-agent-id")
|
|
if err := os.WriteFile(validPath, []byte("agent-safe-123\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(oversizedPath, []byte(strings.Repeat("a", 5000)), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(validPath, symlinkPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := syscall.Mkfifo(fifoPath, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
harness := func(path string) ([]byte, error) {
|
|
script := `
|
|
set -euo pipefail
|
|
COLLECTOR_LIFECYCLE_BINARY_PATH="` + binaryPath + `"
|
|
INSTALL_DIR="` + root + `"
|
|
BINARY_NAME="pulse-agent"
|
|
LEAST_PRIVILEGE_USER="pulse-agent-test-missing"
|
|
` + extractLifecycleTrustShellFunctions(t) + `
|
|
` + extractInstallShellFunction(t, "collector_lifecycle_binary") + `
|
|
` + extractInstallShellFunction(t, "read_agent_id_file_safely") + `
|
|
read_agent_id_file_safely "` + path + `"
|
|
`
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
return exec.CommandContext(ctx, "bash", "-c", script).CombinedOutput()
|
|
}
|
|
if out, err := harness(validPath); err != nil || strings.TrimSpace(string(out)) != "agent-safe-123" {
|
|
t.Fatalf("valid descriptor-bound agent ID recovery failed: %v\n%s", err, out)
|
|
}
|
|
for _, path := range []string{symlinkPath, fifoPath, oversizedPath} {
|
|
started := time.Now()
|
|
if out, err := harness(path); err == nil {
|
|
t.Fatalf("unsafe agent ID path %s was accepted:\n%s", path, out)
|
|
}
|
|
if elapsed := time.Since(started); elapsed >= 2*time.Second {
|
|
t.Fatalf("unsafe agent ID path %s blocked for %s", path, elapsed)
|
|
}
|
|
}
|
|
}
|
|
|
|
type agentLifecycleControlPlane struct {
|
|
mu sync.Mutex
|
|
online bool
|
|
bootstrapToken string
|
|
runtimeToken string
|
|
canonicalID string
|
|
enrollmentCount int
|
|
reportCount int
|
|
lastReportToken string
|
|
lastReportID string
|
|
lastCommands bool
|
|
}
|
|
|
|
type agentLifecycleSnapshot struct {
|
|
online bool
|
|
enrollmentCount int
|
|
reportCount int
|
|
lastReportToken string
|
|
lastReportID string
|
|
lastCommands bool
|
|
}
|
|
|
|
func (s *agentLifecycleControlPlane) setCredentials(bootstrapToken, runtimeToken, canonicalID string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.bootstrapToken = bootstrapToken
|
|
s.runtimeToken = runtimeToken
|
|
s.canonicalID = canonicalID
|
|
}
|
|
|
|
func (s *agentLifecycleControlPlane) setOnline(online bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.online = online
|
|
}
|
|
|
|
func (s *agentLifecycleControlPlane) snapshot() agentLifecycleSnapshot {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return agentLifecycleSnapshot{
|
|
online: s.online,
|
|
enrollmentCount: s.enrollmentCount,
|
|
reportCount: s.reportCount,
|
|
lastReportToken: s.lastReportToken,
|
|
lastReportID: s.lastReportID,
|
|
lastCommands: s.lastCommands,
|
|
}
|
|
}
|
|
|
|
func (s *agentLifecycleControlPlane) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
if !s.online {
|
|
s.mu.Unlock()
|
|
http.Error(w, "server restarting", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
bootstrapToken := s.bootstrapToken
|
|
runtimeToken := s.runtimeToken
|
|
canonicalID := s.canonicalID
|
|
s.mu.Unlock()
|
|
|
|
switch {
|
|
case r.URL.Path == "/api/agents/agent/lookup":
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
case strings.HasPrefix(r.URL.Path, "/api/agents/agent/") && strings.HasSuffix(r.URL.Path, "/config"):
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"success":true,"config":{}}`)
|
|
case r.URL.Path == "/api/agents/agent/enroll":
|
|
if got := r.Header.Get("X-API-Token"); got != bootstrapToken {
|
|
http.Error(w, "bad bootstrap token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
var payload struct {
|
|
CommandsEnabled bool `json:"commandsEnabled"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.enrollmentCount++
|
|
s.lastCommands = payload.CommandsEnabled
|
|
s.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"agentId": canonicalID,
|
|
"runtimeToken": runtimeToken,
|
|
})
|
|
case r.URL.Path == "/api/agents/agent/report":
|
|
token := r.Header.Get("X-API-Token")
|
|
if token != runtimeToken {
|
|
http.Error(w, "bad runtime token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
reportID := ""
|
|
if gz, err := gzip.NewReader(r.Body); err == nil {
|
|
var report struct {
|
|
Agent struct {
|
|
ID string `json:"id"`
|
|
} `json:"agent"`
|
|
}
|
|
if json.NewDecoder(gz).Decode(&report) == nil {
|
|
reportID = report.Agent.ID
|
|
}
|
|
_ = gz.Close()
|
|
}
|
|
s.mu.Lock()
|
|
s.reportCount++
|
|
s.lastReportToken = token
|
|
s.lastReportID = reportID
|
|
s.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"success": true,
|
|
"agentId": canonicalID,
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
func waitForLifecycleState(t *testing.T, timeout time.Duration, describe string, predicate func(agentLifecycleSnapshot) bool, state *agentLifecycleControlPlane) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
snapshot := state.snapshot()
|
|
if predicate(snapshot) {
|
|
return
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
final := state.snapshot()
|
|
t.Fatalf("timed out waiting for %s; enrollments=%d reports=%d report_id=%q commands=%t",
|
|
describe, final.enrollmentCount, final.reportCount, final.lastReportID, final.lastCommands)
|
|
}
|
|
|
|
func buildLifecycleAgent(t *testing.T) string {
|
|
t.Helper()
|
|
binaryPath := filepath.Join(t.TempDir(), "pulse-agent")
|
|
cmd := exec.Command("go", "build", "-o", binaryPath, "./cmd/pulse-agent")
|
|
cmd.Dir = repoFile()
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("build pulse-agent: %v\n%s", err, output)
|
|
}
|
|
return binaryPath
|
|
}
|
|
|
|
func renderLifecycleService(t *testing.T, stateDir, stateSource, unitPath, pulseURL, token string, commandsEnabled, recoverExisting bool) {
|
|
t.Helper()
|
|
commandFlag := "false"
|
|
if commandsEnabled {
|
|
commandFlag = "true"
|
|
}
|
|
recovery := ""
|
|
if recoverExisting {
|
|
recovery = `
|
|
PULSE_URL=""
|
|
PULSE_TOKEN=""
|
|
AGENT_ID=""
|
|
HOSTNAME_OVERRIDE=""
|
|
REPORT_IP=""
|
|
INSECURE="false"
|
|
SERVER_FINGERPRINT=""
|
|
CURL_CA_BUNDLE=""
|
|
recover_connection_state "$STATE_DIR/connection.env"
|
|
`
|
|
}
|
|
script := `
|
|
set -euo pipefail
|
|
STATE_DIR="` + stateDir + `"
|
|
DEFAULT_STATE_DIR="` + stateDir + `"
|
|
STATE_DIR_SOURCE="` + stateSource + `"
|
|
TRUENAS_STATE_DIR="` + filepath.Join(filepath.Dir(stateDir), "truenas-state") + `"
|
|
PULSE_URL="` + pulseURL + `"
|
|
IFS= read -r PULSE_TOKEN
|
|
INTERVAL="1s"
|
|
ENABLE_HOST="true"
|
|
ENABLE_DOCKER="false"
|
|
DOCKER_EXPLICIT="true"
|
|
ENABLE_KUBERNETES="false"
|
|
KUBECONFIG_PATH=""
|
|
ENABLE_PROXMOX="false"
|
|
PROXMOX_TYPE=""
|
|
INSECURE="false"
|
|
SERVER_FINGERPRINT=""
|
|
OBSERVERS_FILE=""
|
|
ENABLE_COMMANDS="` + commandFlag + `"
|
|
LEAST_PRIVILEGE="false"
|
|
LEAST_PRIVILEGE_USER="pulse-agent"
|
|
INSTALLER_LIFECYCLE_DIR="` + filepath.Join(filepath.Dir(stateDir), "installer-lifecycle") + `"
|
|
GRANT_SMART="false"
|
|
GRANT_PCT="false"
|
|
HEALTH_ADDR_SET="true"
|
|
HEALTH_ADDR=""
|
|
ENROLL="true"
|
|
KUBE_INCLUDE_ALL_PODS="false"
|
|
KUBE_INCLUDE_ALL_DEPLOYMENTS="false"
|
|
AGENT_ID=""
|
|
HOSTNAME_OVERRIDE="state-lifecycle-host"
|
|
REPORT_IP=""
|
|
DISK_EXCLUDES=()
|
|
CURL_CA_BUNDLE=""
|
|
RUNTIME_TOKEN_FILE=""
|
|
RUNTIME_TOKEN_CHANGED="false"
|
|
SYSTEMD_ENV_LINES=""
|
|
SHELL_EXPORT_LINES=""
|
|
SAVED_INSTALL_SCRIPT=""
|
|
EXIT_GENERAL=1
|
|
TMP_FILES=()
|
|
NON_INTERACTIVE="true"
|
|
log_info() { :; }
|
|
log_warn() { :; }
|
|
fail() { printf 'FAIL:%s\n' "$1" >&2; return 99; }
|
|
curl() { return 1; }
|
|
` + extractLifecyclePersistenceShellFunctions(t) + `
|
|
` + extractInstallShellFunction(t, "write_connection_state_value") + `
|
|
` + extractInstallShellFunction(t, "read_connection_state_value") + `
|
|
` + extractTokenRecoveryShellFunctions(t) + `
|
|
` + extractInstallShellFunction(t, "recover_connection_state") + `
|
|
` + extractInstallShellFunction(t, "ensure_runtime_token_file") + `
|
|
` + extractInstallShellFunction(t, "build_exec_arg_items") + `
|
|
` + extractInstallShellFunction(t, "join_exec_arg_items") + `
|
|
` + extractInstallShellFunction(t, "build_exec_args") + `
|
|
` + extractInstallShellFunction(t, "systemd_agent_requires_lxc_attach") + `
|
|
` + extractInstallShellFunction(t, "systemd_agent_may_attach_lxc") + `
|
|
` + extractInstallShellFunction(t, "render_systemd_agent_unit") + `
|
|
` + extractInstallShellFunction(t, "save_connection_info") + recovery + `
|
|
ensure_runtime_token_file "$STATE_DIR"
|
|
build_exec_args
|
|
render_systemd_agent_unit "` + unitPath + `" "/test/pulse-agent" "$EXEC_ARGS" "network-online.target" "network-online.target" "root" ""
|
|
save_connection_info "$STATE_DIR"
|
|
`
|
|
cmd := exec.Command("bash", "-c", script)
|
|
cmd.Stdin = strings.NewReader(token + "\n")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("render lifecycle service: %v\n%s", err, out)
|
|
}
|
|
}
|
|
|
|
type runningLifecycleAgent struct {
|
|
cmd *exec.Cmd
|
|
logs *bytes.Buffer
|
|
}
|
|
|
|
func startLifecycleAgent(t *testing.T, binaryPath, pulseURL, stateDir string, commandsEnabled bool) *runningLifecycleAgent {
|
|
t.Helper()
|
|
args := []string{
|
|
"--url", pulseURL,
|
|
"--token-file", filepath.Join(stateDir, "token"),
|
|
"--state-dir", stateDir,
|
|
"--interval", "1s",
|
|
"--hostname", "state-lifecycle-host",
|
|
"--enable-host",
|
|
"--enable-docker=false",
|
|
"--disable-auto-update",
|
|
"--health-addr", "",
|
|
"--enroll",
|
|
}
|
|
if commandsEnabled {
|
|
args = append(args, "--enable-commands")
|
|
}
|
|
logs := &bytes.Buffer{}
|
|
cmd := exec.Command(binaryPath, args...)
|
|
cmd.Env = append(os.Environ(), "PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED=false")
|
|
cmd.Stdout = logs
|
|
cmd.Stderr = logs
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start pulse-agent: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
}
|
|
})
|
|
for _, arg := range cmd.Args {
|
|
if strings.Contains(arg, "bootstrap-") || strings.Contains(arg, "runtime-") {
|
|
t.Fatalf("agent argv leaked token: %q", cmd.Args)
|
|
}
|
|
}
|
|
return &runningLifecycleAgent{cmd: cmd, logs: logs}
|
|
}
|
|
|
|
func (p *runningLifecycleAgent) stop(t *testing.T) string {
|
|
t.Helper()
|
|
if p.cmd.Process == nil {
|
|
return p.logs.String()
|
|
}
|
|
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
|
t.Fatalf("signal pulse-agent: %v", err)
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- p.cmd.Wait() }()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("pulse-agent shutdown: %v\n%s", err, p.logs.String())
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
_ = p.cmd.Process.Kill()
|
|
t.Fatalf("pulse-agent did not stop\n%s", p.logs.String())
|
|
}
|
|
p.cmd.Process = nil
|
|
return p.logs.String()
|
|
}
|
|
|
|
func assertPrivateLifecycleFile(t *testing.T, path string, want os.FileMode) {
|
|
t.Helper()
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat %s: %v", path, err)
|
|
}
|
|
if runtime.GOOS != "windows" && info.Mode().Perm() != want {
|
|
t.Fatalf("%s mode = %o, want %o", path, info.Mode().Perm(), want)
|
|
}
|
|
}
|
|
|
|
func TestPulseAgentStateDirLifecycleIntegration(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("real agent lifecycle integration")
|
|
}
|
|
binaryPath := buildLifecycleAgent(t)
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
commandsEnabled bool
|
|
customState bool
|
|
}{
|
|
{name: "default_state_commands_disabled", commandsEnabled: false, customState: false},
|
|
{name: "custom_state_commands_enabled", commandsEnabled: true, customState: true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
stateName := "default-state"
|
|
if tc.customState {
|
|
stateName = "custom-state"
|
|
}
|
|
stateDir := filepath.Join(root, stateName)
|
|
unitPath := filepath.Join(root, "pulse-agent.service")
|
|
|
|
controlPlane := &agentLifecycleControlPlane{online: true}
|
|
controlPlane.setCredentials("bootstrap-one", "runtime-one", "agent-one")
|
|
server := httptest.NewServer(http.HandlerFunc(controlPlane.serveHTTP))
|
|
defer server.Close()
|
|
|
|
stateSource := "default"
|
|
if tc.customState {
|
|
stateSource = "explicit"
|
|
}
|
|
renderLifecycleService(t, stateDir, stateSource, unitPath, server.URL, "bootstrap-one", tc.commandsEnabled, false)
|
|
unit, err := os.ReadFile(unitPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(unit), "--state-dir "+stateDir) ||
|
|
!strings.Contains(string(unit), "--token-file "+filepath.Join(stateDir, "token")) {
|
|
t.Fatalf("generated service does not own canonical state paths:\n%s", unit)
|
|
}
|
|
if strings.Contains(string(unit), "bootstrap-one") || strings.Contains(string(unit), "runtime-one") {
|
|
t.Fatalf("generated service leaked a token:\n%s", unit)
|
|
}
|
|
if tc.commandsEnabled != strings.Contains(string(unit), "--enable-commands") {
|
|
t.Fatalf("generated service command mode mismatch:\n%s", unit)
|
|
}
|
|
|
|
proc := startLifecycleAgent(t, binaryPath, server.URL, stateDir, tc.commandsEnabled)
|
|
waitForLifecycleState(t, 20*time.Second, "initial enrollment and report", func(state agentLifecycleSnapshot) bool {
|
|
return state.enrollmentCount == 1 && state.reportCount >= 1 &&
|
|
state.lastReportToken == "runtime-one" && state.lastReportID == "agent-one" &&
|
|
state.lastCommands == tc.commandsEnabled
|
|
}, controlPlane)
|
|
logOutput := proc.stop(t)
|
|
|
|
for path, mode := range map[string]os.FileMode{
|
|
stateDir: 0700,
|
|
filepath.Join(stateDir, "token"): 0600,
|
|
filepath.Join(stateDir, "runtime.token"): 0600,
|
|
filepath.Join(stateDir, "agent-id"): 0600,
|
|
filepath.Join(stateDir, "connection.env"): 0600,
|
|
} {
|
|
assertPrivateLifecycleFile(t, path, mode)
|
|
}
|
|
|
|
beforeRestart := controlPlane.snapshot()
|
|
proc = startLifecycleAgent(t, binaryPath, server.URL, stateDir, tc.commandsEnabled)
|
|
waitForLifecycleState(t, 20*time.Second, "restart report with persisted identity", func(state agentLifecycleSnapshot) bool {
|
|
return state.reportCount > beforeRestart.reportCount &&
|
|
state.lastReportToken == "runtime-one" && state.lastReportID == "agent-one"
|
|
}, controlPlane)
|
|
if got := controlPlane.snapshot().enrollmentCount; got != 1 {
|
|
t.Fatalf("ordinary restart re-enrolled unexpectedly: enrollments=%d", got)
|
|
}
|
|
|
|
controlPlane.setOnline(false)
|
|
reportsBeforeOutage := controlPlane.snapshot().reportCount
|
|
time.Sleep(1500 * time.Millisecond)
|
|
controlPlane.setOnline(true)
|
|
waitForLifecycleState(t, 20*time.Second, "report recovery after server restart", func(state agentLifecycleSnapshot) bool {
|
|
return state.reportCount > reportsBeforeOutage && state.lastReportToken == "runtime-one"
|
|
}, controlPlane)
|
|
logOutput += proc.stop(t)
|
|
|
|
renderLifecycleService(t, stateDir, stateSource, unitPath, server.URL, "bootstrap-one", tc.commandsEnabled, true)
|
|
if _, err := os.Stat(filepath.Join(stateDir, "runtime.token")); err != nil {
|
|
t.Fatalf("update did not preserve runtime enrollment token: %v", err)
|
|
}
|
|
|
|
controlPlane.setCredentials("bootstrap-two", "runtime-two", "agent-two")
|
|
renderLifecycleService(t, stateDir, stateSource, unitPath, server.URL, "bootstrap-two", tc.commandsEnabled, false)
|
|
if _, err := os.Stat(filepath.Join(stateDir, "runtime.token")); !os.IsNotExist(err) {
|
|
t.Fatalf("fresh bootstrap did not clear stale runtime token: %v", err)
|
|
}
|
|
proc = startLifecycleAgent(t, binaryPath, server.URL, stateDir, tc.commandsEnabled)
|
|
waitForLifecycleState(t, 20*time.Second, "re-enrollment and canonical report", func(state agentLifecycleSnapshot) bool {
|
|
return state.enrollmentCount == 2 && state.lastReportToken == "runtime-two" &&
|
|
state.lastReportID == "agent-two" && state.lastCommands == tc.commandsEnabled
|
|
}, controlPlane)
|
|
logOutput += proc.stop(t)
|
|
|
|
for _, secret := range []string{"bootstrap-one", "runtime-one", "bootstrap-two", "runtime-two"} {
|
|
if strings.Contains(logOutput, secret) {
|
|
t.Fatalf("agent logs leaked %q:\n%s", secret, logOutput)
|
|
}
|
|
}
|
|
|
|
removeScript := `
|
|
set -euo pipefail
|
|
STATE_DIR="` + stateDir + `"
|
|
STATE_DIR_REMOVAL_AUTHORITY="$STATE_DIR"
|
|
log_warn() { :; }
|
|
` + extractInstallShellFunction(t, "remove_authorized_runtime_dir") + `
|
|
` + extractInstallShellFunction(t, "remove_agent_state_dir") + `
|
|
remove_agent_state_dir "$STATE_DIR"
|
|
`
|
|
if out, err := exec.Command("bash", "-c", removeScript).CombinedOutput(); err != nil {
|
|
t.Fatalf("uninstall state cleanup: %v\n%s", err, out)
|
|
}
|
|
if _, err := os.Stat(stateDir); !os.IsNotExist(err) {
|
|
t.Fatalf("uninstall did not remove canonical state directory: %v", err)
|
|
}
|
|
|
|
connectionState, err := os.ReadFile(filepath.Join(root, stateName, "connection.env"))
|
|
if err == nil {
|
|
t.Fatalf("connection state survived uninstall: %s", connectionState)
|
|
}
|
|
})
|
|
}
|
|
}
|