package install import ( "errors" "fmt" "os" "path/filepath" "regexp" "strings" "testing" ) // recorder stands in for systemctl and userdel, so the sequence can be tested without systemd. type recorder struct { calls []string // failOn makes a named command fail, for the cases where a missing unit or an absent user must not // abort the uninstall. failOn string } func (r *recorder) Run(name string, args ...string) error { call := name + " " + strings.Join(args, " ") r.calls = append(r.calls, call) if r.failOn != "" && strings.HasPrefix(call, r.failOn) { return fmt.Errorf("simulated failure") } return nil } // RunCaptured is what the install needs to resolve the service user's ids. The real ExecRunner reads // them from id(1); here they are fixed, because what is being tested is that the install asks for them // and applies them, not that this machine has a particular uid. func (r *recorder) RunCaptured(name string, args ...string) (string, error) { r.calls = append(r.calls, name+" "+strings.Join(args, " ")) return "1001", nil } // installed creates the layout an install leaves behind, so the test removes real files. func installed(t *testing.T) Paths { t.Helper() root := t.TempDir() paths := Paths{ ServiceName: "msa-agent", UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"), ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"), LogDir: filepath.Join(root, "var/log/msa-agent"), BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"), User: "msa-agent", } for _, path := range []string{paths.UnitPath, paths.ConfigPath, filepath.Join(paths.LogDir, "audit.log"), paths.BinaryPath} { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { t.Fatal(err) } } return paths } func TestUninstallRemovesEverythingTheInstallPlaced(t *testing.T) { paths := installed(t) runner := &recorder{} var lines []string err := Uninstall(Options{ Paths: paths, Runner: runner, Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }, NotifyRelay: func() error { lines = append(lines, "relay notified") return nil }, }) if err != nil { t.Fatalf("Uninstall: %v", err) } for _, path := range []string{paths.UnitPath, paths.ConfigPath, paths.LogDir, paths.BinaryPath} { if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Fatalf("%s survived the uninstall", path) } } // The config directory goes with the config: it holds relay-ca.pem and anything else the operator // put beside it. if _, err := os.Stat(filepath.Dir(paths.ConfigPath)); !errors.Is(err, os.ErrNotExist) { t.Fatal("the config directory survived; it may still hold pinned CA material") } joined := strings.Join(runner.calls, "\n") for _, want := range []string{"systemctl disable --now msa-agent", "systemctl daemon-reload", "userdel msa-agent"} { if !strings.Contains(joined, want) { t.Fatalf("expected %q among the commands, got:\n%s", want, joined) } } } // The order is the point: the relay has to be told before the node stops existing, or it keeps routing // to a node that is gone and a phone that believes it is paired points at nothing. func TestUninstallTellsTheRelayBeforeItRemovesAnything(t *testing.T) { paths := installed(t) configStillThere := false err := Uninstall(Options{ Paths: paths, Runner: &recorder{}, Log: func(string, ...any) {}, NotifyRelay: func() error { // At the moment the relay is told, the config must still exist: the notification reads it. _, statErr := os.Stat(paths.ConfigPath) configStillThere = statErr == nil return nil }, }) if err != nil { t.Fatalf("Uninstall: %v", err) } if !configStillThere { t.Fatal("the config was removed before the relay was told; the device list could not be read") } } // A relay that cannot be reached must not stop an uninstall. The operator is removing the node either // way, and the alternative — aborting half way — leaves the machine in a state nobody described. func TestUninstallCompletesWhenTheRelayIsUnreachable(t *testing.T) { paths := installed(t) var lines []string err := Uninstall(Options{ Paths: paths, Runner: &recorder{}, Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }, NotifyRelay: func() error { return fmt.Errorf("relay unreachable") }, }) if err != nil { t.Fatalf("Uninstall: %v", err) } if _, err := os.Stat(paths.ConfigPath); !errors.Is(err, os.ErrNotExist) { t.Fatal("the uninstall did not finish") } // And the operator is told what is left to do by hand, because the relay still lists this node. output := strings.Join(lines, "\n") if !strings.Contains(output, "WARNING") || !strings.Contains(output, "agents.json") { t.Fatalf("a failed relay notification was not explained:\n%s", output) } } // A dry run removes nothing, and that includes not stopping the service: an operator asking what an // uninstall would do must not lose the node while they read the answer. func TestDryRunRemovesNothingAndRunsNothing(t *testing.T) { paths := installed(t) runner := &recorder{} var lines []string err := Uninstall(Options{ Paths: paths, DryRun: true, Runner: runner, Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }, }) if err != nil { t.Fatalf("Uninstall: %v", err) } if len(runner.calls) != 0 { t.Fatalf("a dry run ran commands: %v", runner.calls) } for _, path := range []string{paths.UnitPath, paths.ConfigPath, paths.BinaryPath} { if _, err := os.Stat(path); err != nil { t.Fatalf("a dry run removed %s", path) } } if !strings.Contains(strings.Join(lines, "\n"), "dry run: nothing was removed") { t.Fatal("a dry run did not say so") } } func TestKeepLogsLeavesTheAuditLog(t *testing.T) { paths := installed(t) audit := filepath.Join(paths.LogDir, "audit.log") err := Uninstall(Options{ Paths: paths, KeepLogs: true, Runner: &recorder{}, Log: func(string, ...any) {}, }) if err != nil { t.Fatalf("Uninstall: %v", err) } if _, err := os.Stat(audit); err != nil { t.Fatalf("--keep-logs removed the audit log: %v", err) } } // A missing unit or an absent user is the normal case on a partially installed node, and it must not // abort: reporting it as an error would train the operator to ignore the errors that matter. func TestMissingPiecesDoNotAbortTheUninstall(t *testing.T) { paths := installed(t) // Remove the unit first, then uninstall. if err := os.Remove(paths.UnitPath); err != nil { t.Fatal(err) } runner := &recorder{failOn: "systemctl disable"} err := Uninstall(Options{ Paths: paths, Runner: runner, Log: func(string, ...any) {}, }) if err != nil { t.Fatalf("a missing unit aborted the uninstall: %v", err) } if _, err := os.Stat(paths.ConfigPath); !errors.Is(err, os.ErrNotExist) { t.Fatal("the config survived") } } // The plan names every path, so an operator can check it before running anything. func TestThePlanNamesEveryPath(t *testing.T) { plan := DefaultPaths().String() for _, want := range []string{"/etc/systemd/system/msa-agent.service", "/etc/msa-agent/config.json", "/var/log/msa-agent", "/opt/msa-agent/msa-agent", "msa-agent"} { if !strings.Contains(plan, want) { t.Fatalf("the plan does not mention %s:\n%s", want, plan) } } } // The unit is embedded, and the vendored copy is produced by the Makefile. This is the drift guard: an // embedded file edited in place is a file the build overwrites, and the mistake should fail here rather // than ship as a node running a unit nobody reviewed. func TestVendoredUnitMatchesSystemd(t *testing.T) { upstream, err := os.ReadFile(filepath.Join("..", "..", "systemd", "msa-agent.service")) if err != nil { t.Fatalf("read systemd/msa-agent.service: %v", err) } if string(upstream) != string(UnitFile()) { t.Fatal("internal/install/msa-agent.service differs from systemd/msa-agent.service; run make -C agent vendor") } } // The install has to produce what the unit requires, and the two live in different files. These are the // three requirements the first version of the install command missed, each of which fails on the node: // // 1. the unit names a user, and nothing created it; // 2. `enroll` runs under sudo, so the config is root-owned 0600 and the service cannot read its own keys; // 3. the unit was copied from a path in the repository, which a node does not have. func TestInstallSatisfiesWhatTheUnitRequires(t *testing.T) { unit := string(UnitFile()) user := regexp.MustCompile(`(?m)^User=(\S+)$`).FindStringSubmatch(unit) if user == nil { t.Skip("the unit no longer names a user, so there is nothing to reconcile") } serviceUser := user[1] root := t.TempDir() paths := Paths{ ServiceName: "msa-agent", UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"), ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"), LogDir: filepath.Join(root, "var/log/msa-agent"), BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"), User: serviceUser, } for _, path := range []string{paths.BinaryPath, paths.ConfigPath} { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { t.Fatal(err) } } // A fresh node: the service user does not exist yet, so the existence check must fail. The recorder // succeeds at everything by default, which would model a node where the user was already there — the // idempotent path, and not the one this test is about. runner := &recorder{failOn: "getent"} var lines []string err := Install(InstallOptions{ Paths: paths, Runner: runner, Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }, }) if err != nil { t.Fatalf("Install: %v", err) } // 1. The user is created with the flags a service account needs. calls := strings.Join(runner.calls, "\n") if !strings.Contains(calls, "useradd --system --no-create-home") { t.Fatalf("the service user is never created:\n%s", calls) } // 2. Ownership is moved to that user, so the service can read its own config. if !strings.Contains(strings.Join(lines, "\n"), "giving "+serviceUser+" ownership") { t.Fatalf("the config is left owned by root, so the service cannot read it:\n%s", strings.Join(lines, "\n")) } // 3. The unit is written from the binary, not copied from a repository path. if _, err := os.Stat(paths.UnitPath); err != nil { t.Fatalf("the unit was not written: %v", err) } if strings.Contains(calls, "install -m 0644") { t.Fatal("the unit is copied from a path a node does not have") } // And the service is enabled and started, last. if !strings.Contains(calls, "systemctl enable --now msa-agent") { t.Fatalf("the service is never started:\n%s", calls) } } // A dry run of the install changes nothing either, for the same reason as the uninstall's. func TestInstallDryRunChangesNothing(t *testing.T) { root := t.TempDir() paths := Paths{ ServiceName: "msa-agent", UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"), ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"), LogDir: filepath.Join(root, "var/log/msa-agent"), BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"), User: "msa-agent", } for _, path := range []string{paths.BinaryPath, paths.ConfigPath} { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { t.Fatal(err) } } runner := &recorder{} if err := Install(InstallOptions{Paths: paths, DryRun: true, Runner: runner, Log: func(string, ...any) {}}); err != nil { t.Fatalf("Install: %v", err) } if len(runner.calls) != 0 { t.Fatalf("a dry run ran commands: %v", runner.calls) } if _, err := os.Stat(paths.UnitPath); !errors.Is(err, os.ErrNotExist) { t.Fatal("a dry run wrote the unit") } if _, err := os.Stat(paths.LogDir); !errors.Is(err, os.ErrNotExist) { t.Fatal("a dry run created the log directory") } } // An install with no config is refused before anything is changed, because `enroll` is what mints the // node's keys: a service installed without one starts, fails to load its config, and restarts forever. func TestInstallRefusesWithoutAConfigOrBinary(t *testing.T) { root := t.TempDir() paths := Paths{ ServiceName: "msa-agent", UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"), ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"), LogDir: filepath.Join(root, "var/log/msa-agent"), BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"), User: "msa-agent", } runner := &recorder{} log := func(string, ...any) {} // Nothing exists at all. if err := Install(InstallOptions{Paths: paths, Runner: runner, Log: log}); err == nil { t.Fatal("installed without a binary") } // The binary but no config. if err := os.MkdirAll(filepath.Dir(paths.BinaryPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(paths.BinaryPath, []byte("x"), 0o755); err != nil { t.Fatal(err) } err := Install(InstallOptions{Paths: paths, Runner: runner, Log: log}) if err == nil || !strings.Contains(err.Error(), "enroll") { t.Fatalf("expected a refusal that names enroll, got %v", err) } if len(runner.calls) != 0 { t.Fatalf("a refused install still ran commands: %v", runner.calls) } } // A dry run is asked before the work is done, so it must not refuse for the very conditions it exists // to reveal. An operator on a node that has neither the binary nor the config — the normal state before // an install — is exactly who runs it. func TestInstallDryRunReportsPreconditionsInsteadOfRefusing(t *testing.T) { root := t.TempDir() paths := Paths{ ServiceName: "msa-agent", UnitPath: filepath.Join(root, "etc/systemd/system/msa-agent.service"), ConfigPath: filepath.Join(root, "etc/msa-agent/config.json"), LogDir: filepath.Join(root, "var/log/msa-agent"), BinaryPath: filepath.Join(root, "opt/msa-agent/msa-agent"), User: "msa-agent", } var lines []string err := Install(InstallOptions{ Paths: paths, DryRun: true, Runner: &recorder{}, Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }, }) if err != nil { t.Fatalf("a dry run on a fresh node was refused: %v", err) } output := strings.Join(lines, "\n") // Both missing preconditions are named, so the operator learns what the real run needs. if !strings.Contains(output, "msa-agent is not there yet") || !strings.Contains(output, "enroll") { t.Fatalf("the dry run did not name the missing pieces:\n%s", output) } // And the plan is still printed. if !strings.Contains(output, "would writing") { t.Fatalf("the dry run printed no plan:\n%s", output) } } // The hint at the end has to name the right place, because the two records live in different places and // are removed by different people. Telling the operator the wrong one sends them to the wrong place. func TestTheRemovalHintIsTheOneItWasGiven(t *testing.T) { paths := installed(t) var lines []string err := Uninstall(Options{ Paths: paths, Runner: &recorder{}, Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }, RemovalHint: "the relay still lists this node in its enrollment state; remove it in the app", }) if err != nil { t.Fatalf("Uninstall: %v", err) } output := strings.Join(lines, "\n") if !strings.Contains(output, "remove it in the app") { t.Fatalf("the hint was not printed:\n%s", output) } // And the old hard-coded sentence is gone: it named agents.json for every node, including the // self-enrolled ones that are not there. if strings.Contains(output, "agents.json still lists this node") { t.Fatalf("the uninstall still claims every node is in agents.json:\n%s", output) } }