节点侧现在有两条命令覆盖整个生命周期,中继侧有了一条让节点不必被人工登记就能加入 的通道。 agent 新增两个子命令: - `msa-agent enroll --relay <url> [--code <code>]` 铸造密钥与标识并写配置。它和 `msa-keygen` 共用 internal/enroll,所以两者不会对"配置长什么样"产生分歧——区别只在 运行位置:keygen 需要 Go 工具链,而节点上只有交叉编译好的二进制。带 --code 时它自己 向中继登记,顺序是先登记再落盘:为"中继还不认识的节点"写下的配置会永远重试 401,而 运维无法把这种情况和网络故障区分开。 - `msa-agent uninstall [--dry-run] [--keep-logs]` 按唯一一个不会留下悬挂状态的顺序拆除: 先清设备并告诉中继,再停服务、删单元、删配置目录、删日志、删用户。顺序是重点——先删 节点会让中继继续向一台已经不存在的节点投递,而"一键卸载"此前根本不存在,尽管安装会 往宿主机放六样东西,其中配置里还有节点密码。 写这个时踩到一个值得记的坑:第一版把每个动作都写成"先 log 再执行",于是 --dry-run 会真的停掉服务——一个不是 dry run 的 dry run。现在每个副作用都 gate 在 step() 的 返回值上,并有测试断言 dry run 一条命令都不跑。 中继新增自注册,默认关闭: - `POST /v1/enroll/code`(运维令牌)铸造一次性码,15 分钟、最多 10 个未使用。 - `POST /v1/enroll` 节点用码登记自己。码先兑换再校验登记体,所以用垃圾请求探测码是否 有效的尝试会烧掉它。 - 登记只授权"加入"这一件事。中继仍然读不到内容、伪造不了应答、下发不了命令——最坏情况 是让一个不该出现的节点出现,也就是拒绝服务,正是设计已经接受的边界。 - agents.json 的条目优先于自注册状态,所以运维永远能用一个 diff 覆盖或吊销自注册的节点; 反过来,节点不能覆盖运维条目(被拒绝,400)。 - 自注册状态要落盘,这是中继第一次有它必须记住的东西。中继此前无状态是它的一条性质, 这里明确让掉了:忘记已登记的节点会让它们全部 401,而症状看起来像 token 坏了。状态文件 原子写、损坏时拒绝启动(而不是静默变成空机群),systemd 单元用 StateDirectory 给它一块 可写目录,其余文件系统仍是只读。 测试:internal/enroll 五项(两份产物必须描述同一个节点、密码只在给出时写入、给定标识 不被重新生成、两次铸造不同、缺输入被拒),internal/install 七项(删干净、顺序、中继不可 达仍完成、dry run 不动任何东西、保留日志、缺件不中止、计划列出每条路径), test/enrollment.test.js 十四项(流程、码单次、垃圾请求烧码、默认关闭、只有运维能铸造、 不能覆盖运维条目、可重复登记、重启后仍在、运维优先、损坏状态拒绝启动、码过期与清扫、 铸造循环不涨内存、登记体校验、状态文件不含私钥)。变异验证:去掉码的单次性、去掉运维条目 保护、让 dry run 执行命令、先删配置再通知中继——四处都让对应测试失败。 Go 全包、Node 134 项、Swift 107 项通过。
211 lines
6.9 KiB
Go
211 lines
6.9 KiB
Go
package install
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|