fix(install): 刚装完的服务会失败,单元要说清楚而不是每 5 秒重启一次

我在真内核上把安装序列完整走了一遍(容器里补了 useradd/systemctl 的替身,其余全是真的),
暴露出一个只有真跑才看得见的缺陷:

装完 → `install` 启动服务 → 但那时**还没有配对的手机**,而守护进程**故意**拒绝空的设备列表
(一个什么都不能授权的 agent 不该坐在那里显得健康)。于是 `Restart=always` + `RestartSec=5`
变成每 5 秒重启一次,永远,journal 里刷同一行。实测:exit 1,"devices is empty, so no phone
could ever be authorised"。

配对发生在安装**之后**(运维在节点上跑 `--pair`,然后在手机上配对),所以这不是异常状态,
而是流程的必经一步。修法两条:

- 单元加 `StartLimitIntervalSec=300` / `StartLimitBurst=10`:十次之后 systemd 放弃,单元进入
  `failed`——一个运维看得见、能处理的状态,而不是无限重启。
- `install` 的结束语改成说清这一步:服务会退出并停住,下一步是 `--pair`,用
  `systemctl status` 看状态。此前它说"installed and running",而那是假的。

同一轮演练确认了此前所有修复在真内核上成立:服务用户建了、配置与日志目录交给它了、配置对
该用户可读、单元来自二进制且内容一致、卸载按序拆干净(用户/目录/单元全无残留)。

Go 全包通过(新增一项守着"单元必须有启动上限,且 install 必须说出下一步")。
This commit is contained in:
chick
2026-09-18 01:09:15 +08:00
parent fc7a77c500
commit 9949cfe5b0
5 changed files with 92 additions and 3 deletions
+48
View File
@@ -426,3 +426,51 @@ func TestTheRemovalHintIsTheOneItWasGiven(t *testing.T) {
t.Fatalf("the uninstall still claims every node is in agents.json:\n%s", output)
}
}
// The install ends by starting a service that cannot run yet, and the unit has to say what to do about
// that. A freshly installed node has no paired devices — pairing happens after install — and the daemon
// refuses to run in that state on purpose, because an agent that could authorise nothing must not look
// healthy. With `Restart=always` and `RestartSec=5` and no start limit, that is a restart every five
// seconds until a phone pairs.
//
// Found by rehearsing the install on a real kernel: exit 1, "devices is empty, so no phone could ever be
// authorised", and the journal filling with the same line.
func TestTheUnitGivesUpInsteadOfRestartingForever(t *testing.T) {
unit := string(UnitFile())
if !strings.Contains(unit, "Restart=always") {
t.Skip("the unit no longer restarts, so there is nothing to bound")
}
if !strings.Contains(unit, "StartLimitIntervalSec=") || !strings.Contains(unit, "StartLimitBurst=") {
t.Fatal("the unit restarts without a start limit: an unpaired node would restart every RestartSec forever")
}
// And the install tells the operator what to do next, so the failed state is expected rather than
// surprising.
var lines []string
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)
}
}
if err := Install(InstallOptions{
Paths: paths,
Runner: &recorder{failOn: "getent"},
Log: func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) },
}); err != nil {
t.Fatalf("Install: %v", err)
}
if !strings.Contains(strings.Join(lines, "\n"), "--pair") {
t.Fatalf("the install does not say to pair next:\n%s", strings.Join(lines, "\n"))
}
}