我在真内核上把安装序列完整走了一遍(容器里补了 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 必须说出下一步")。
179 lines
7.8 KiB
Go
179 lines
7.8 KiB
Go
// Package install puts the agent on a node and takes it off again, as the two halves of one sequence.
|
|
//
|
|
// It is one package because the two have to agree about every path. An install that creates something
|
|
// the uninstall does not remove leaves a machine with a stray credential on it, and an uninstall that
|
|
// removes something the install did not create fails on a fresh node. Keeping the path list in one
|
|
// place is what makes that agreement checkable rather than remembered.
|
|
//
|
|
// The systemd unit is embedded rather than fetched. Two reasons, and the second is the one that
|
|
// decided it: a node installing the agent may have no route to anything except the relay, and a unit
|
|
// file downloaded from a URL is a second thing that can 404 at the exact moment the operator has the
|
|
// least context to debug it. Embedding also means the unit an operator runs is the one the binary was
|
|
// built with, so a node cannot end up with a unit from a different release than its agent.
|
|
package install
|
|
|
|
import (
|
|
_ "embed"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// The vendored unit. It is copied from agent/systemd/ by the Makefile, and
|
|
// TestVendoredUnitMatchesSystemd fails when the copy drifts — the same arrangement the agent's policy
|
|
// uses, and for the same reason: an embedded file that is edited in place is a file the build
|
|
// overwrites, and the mistake should be loud rather than silent.
|
|
//
|
|
//go:embed msa-agent.service
|
|
var embeddedUnit []byte
|
|
|
|
// UnitFile returns the unit this binary installs, for tests and for `install --print-unit`.
|
|
func UnitFile() []byte { return embeddedUnit }
|
|
|
|
// InstallOptions configures an install.
|
|
type InstallOptions struct {
|
|
Paths Paths
|
|
// DryRun prints the plan and changes nothing.
|
|
DryRun bool
|
|
Runner Runner
|
|
Log func(format string, args ...any)
|
|
}
|
|
|
|
// Install makes a node ready to run the agent as a service, assuming the binary is already in place
|
|
// and the config has already been written by `msa-agent enroll`.
|
|
//
|
|
// It is idempotent: every step checks what is already true, so re-running it after an upgrade or a
|
|
// failed attempt is safe. That matters because the alternative — a command that only works on a clean
|
|
// node — makes every partial failure a manual cleanup.
|
|
//
|
|
// The order is the reverse of the uninstall's, and for the same reason: the user has to exist before
|
|
// anything can be owned by it, and the config has to be readable by that user before the service can
|
|
// start.
|
|
func Install(opts InstallOptions) error {
|
|
if opts.Runner == nil {
|
|
opts.Runner = ExecRunner{}
|
|
}
|
|
if opts.Log == nil {
|
|
opts.Log = func(string, ...any) {}
|
|
}
|
|
paths := opts.Paths
|
|
|
|
// The two preconditions, reported rather than enforced during a dry run.
|
|
//
|
|
// A dry run is asked before the work is done — that is the whole point of it — so refusing it for
|
|
// the very conditions it exists to reveal would make it useless on exactly the machine an operator
|
|
// runs it on. The real run refuses, because a service whose ExecStart points at nothing fails with
|
|
// "no such file or directory" in the journal, which reads like a broken build.
|
|
binaryMissing := !exists(paths.BinaryPath)
|
|
configMissing := !exists(paths.ConfigPath)
|
|
if !opts.DryRun {
|
|
if binaryMissing {
|
|
return fmt.Errorf("install: %s is not there; run this from the installed binary, not a copy in /tmp", paths.BinaryPath)
|
|
}
|
|
if configMissing {
|
|
return fmt.Errorf("install: %s is not there; run `msa-agent enroll` first", paths.ConfigPath)
|
|
}
|
|
} else {
|
|
if binaryMissing {
|
|
opts.Log("note: %s is not there yet; the real run would refuse", paths.BinaryPath)
|
|
}
|
|
if configMissing {
|
|
opts.Log("note: %s is not there yet; run `msa-agent enroll` first", paths.ConfigPath)
|
|
}
|
|
}
|
|
|
|
// 1. The system user. `--system` gives it no login shell and no home, which is what a process that
|
|
// only dials out needs; `--no-create-home` keeps the filesystem as it was.
|
|
if stepWith(opts.DryRun, opts.Log, "creating the %s system user", paths.User) {
|
|
if err := opts.Runner.Run("getent", "passwd", paths.User); err == nil {
|
|
opts.Log(" %s already exists", paths.User)
|
|
} else if err := opts.Runner.Run("useradd", "--system", "--no-create-home",
|
|
"--shell", "/usr/sbin/nologin", paths.User); err != nil {
|
|
return fmt.Errorf("install: creating the %s user: %w", paths.User, err)
|
|
}
|
|
}
|
|
|
|
// 2. The log directory, owned by that user. The unit's ProtectSystem=strict makes everything else
|
|
// read-only, so this is one of the two paths the service can write.
|
|
if stepWith(opts.DryRun, opts.Log, "creating %s", paths.LogDir) {
|
|
if err := os.MkdirAll(paths.LogDir, 0o750); err != nil {
|
|
return fmt.Errorf("install: creating the log directory: %w", err)
|
|
}
|
|
}
|
|
|
|
// 3. Ownership of the config, and of the log directory, and that step is load-bearing rather than
|
|
// tidy: `enroll` ran under sudo, so the config is root-owned 0600 and the service — which runs as
|
|
// the user above — cannot read its own private keys. The failure is a service that starts and
|
|
// immediately exits with a permission error.
|
|
if stepWith(opts.DryRun, opts.Log, "giving %s ownership of the config and logs", paths.User) {
|
|
for _, path := range []string{paths.ConfigPath, filepath.Dir(paths.ConfigPath), paths.LogDir} {
|
|
if err := chownTo(path, paths.User, opts); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. The unit, from this binary.
|
|
if stepWith(opts.DryRun, opts.Log, "writing %s", paths.UnitPath) {
|
|
if err := os.MkdirAll(filepath.Dir(paths.UnitPath), 0o755); err != nil {
|
|
return fmt.Errorf("install: creating the unit directory: %w", err)
|
|
}
|
|
if err := os.WriteFile(paths.UnitPath, embeddedUnit, 0o644); err != nil {
|
|
return fmt.Errorf("install: writing the unit: %w", err)
|
|
}
|
|
}
|
|
|
|
// 5. Enable and start. `daemon-reload` first, because systemd caches unit files and would otherwise
|
|
// start whatever it read last.
|
|
if stepWith(opts.DryRun, opts.Log, "reloading systemd") {
|
|
if err := opts.Runner.Run("systemctl", "daemon-reload"); err != nil {
|
|
return fmt.Errorf("install: systemctl daemon-reload: %w", err)
|
|
}
|
|
}
|
|
if stepWith(opts.DryRun, opts.Log, "enabling and starting %s", paths.ServiceName) {
|
|
if err := opts.Runner.Run("systemctl", "enable", "--now", paths.ServiceName); err != nil {
|
|
return fmt.Errorf("install: systemctl enable --now: %w", err)
|
|
}
|
|
}
|
|
|
|
if opts.DryRun {
|
|
opts.Log("dry run: nothing was changed")
|
|
return nil
|
|
}
|
|
// The service will fail until a phone is paired, and that is expected rather than a fault: the daemon
|
|
// refuses an empty device list so it cannot sit there looking healthy while authorising nothing. The
|
|
// unit's start limit turns that into a `failed` state instead of a restart loop, and this line is
|
|
// what makes the state expected.
|
|
opts.Log("msa-agent installed and enabled")
|
|
opts.Log("next: run `msa-agent --pair` once and pair the phone; until then the service exits and stops")
|
|
opts.Log("check it with: systemctl status %s; journalctl -u %s -n 20", paths.ServiceName, paths.ServiceName)
|
|
return nil
|
|
}
|
|
|
|
// exists reports whether a path is present, without caring why it might not be.
|
|
func exists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// chownTo gives a path to the service user.
|
|
//
|
|
// It shells out to chown rather than calling os.Chown, and that is a deliberate consistency rather than
|
|
// a preference: the whole install is a sequence of system commands, so routing all of them through the
|
|
// Runner is what makes the sequence testable without root. os.Chown would work and would also be the one
|
|
// step a test could not intercept.
|
|
func chownTo(path, user string, opts InstallOptions) error {
|
|
if err := opts.Runner.Run("chown", user+":"+user, path); err != nil {
|
|
return fmt.Errorf("install: giving %s to %s: %w", path, user, err)
|
|
}
|
|
opts.Log(" %s -> %s", path, user)
|
|
return nil
|
|
}
|
|
|
|
// PrintUnit writes the embedded unit to w, so an operator can read what will be installed before it is.
|
|
func PrintUnit(w io.Writer) error {
|
|
_, err := w.Write(embeddedUnit)
|
|
return err
|
|
}
|