fix(agent): 补上安装的服务那一半——建用户、改所有权、装单元
上一轮那段"一键安装"是坏的,而且只在节点上坏。实测三段里漏了三件: 1. systemd 单元写着 `User=msa-agent`,而命令从没创建这个用户; 2. `enroll` 是 sudo 跑的,配置是 root 所有 0600,服务以另一个用户运行——读不到自己的私钥; 3. 单元从 `msa-agent.service` 这个仓库里的路径拷,而节点上没有仓库。 三段都"看起来对",因为它们各自是一行合理的 shell;错的是它们与单元之间的接缝。这正 是这个项目里反复出现的那一类缺陷。 现在有 `msa-agent install`: - 建 `--system --no-create-home --shell /usr/sbin/nologin` 的用户(已存在则跳过); - 建日志目录,并把配置、配置目录、日志目录 chown 给该用户; - 把 systemd 单元写进去。单元**嵌在二进制里**(`go:embed`),不是从仓库路径拷的,也不是 从 URL 下的——一个能发 unit 的服务器就能给节点塞一个后门 unit,那与"中继不可信"是同一 条理由。`--print-unit` 可以在装之前把它打出来看; - `daemon-reload` 并 enable/start。 幂等:每步先看已经是什么状态,所以升级或上次失败后重跑都安全。`--dry-run` 打印计划且什么 都不改,并且在缺二进制或配置时**不拒绝**——"还没装"正是会去跑 dry-run 的状态,它只把缺的 东西指出来。chown 走 Runner 而不是 os.Chown,这样整条序列都能在无 root 的情况下测。 install 与 uninstall 共用同一份 `Paths`:装了什么没卸掉会在机器上留一份凭据,卸了什么没装 过会在新节点上失败。单元也一样,`TestVendoredUnitMatchesSystemd` 守着嵌入副本与 `systemd/msa-agent.service` 不漂移。 手机上的安装命令相应简化:`install` 那一步取代了原先手工的 useradd/install/systemctl 三行, 卸载那段也先给 `--dry-run`。 测试新增五项:单元不漂移、install 满足单元的全部要求(用户、所有权、单元来自二进制而非仓库 路径)、install dry run 什么都不改、缺二进制或配置时拒绝且一条命令都不跑、dry run 在全新 节点上不拒绝而是指出缺什么。变异验证:去掉 chown、去掉建用户、让 dry run 执行命令——三处都 让对应测试失败。 Go 全包、Node 134 项、Swift 116 项通过。
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -26,6 +27,14 @@ func (r *recorder) Run(name string, args ...string) error {
|
||||
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()
|
||||
@@ -208,3 +217,187 @@ func TestThePlanNamesEveryPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user