Files
HomeAgent/cmd/homed/guard_lifecycle_test.go
JianFeeeee a7f89a1b45 fix(guard)!: L2 自杀防护 + 配置零值兜底 + 补齐零覆盖的测试
部署 guard 前的审查发现两个**致命**缺陷。它们此前都零测试覆盖 ——
正是「没测过才敢发现」。

## ① L2 自杀循环(启用 guard 就会触发)

doLastResort(restart_app) 执行 `systemctl restart <unit>`,而启用 guard 的
标准做法正是让 systemd 管理 `homed --role=guard` —— <unit> 就是它自己:

    guard: doLastResort → systemctl restart homeagent
           → 杀掉自己 → systemd 按 Restart=always 拉起新 guard
           → 新 guard 再 spawn 失败 → 再自杀 …… 无限循环

每轮白跑一遍完整启动。与 2026-09-05 那次 systemd 审计记录的事故同类
(坏配置导致 836 次重启、79 分钟)。

### 修法:自指时直接退出,而不是换个单元名

「换个别的服务」语义完全不对 —— L2 的本意就是重启自己。而
`Restart=always` 已经提供了这件事:**guard 退出 == systemd 重新拉起 ==
重启整个单元**。所以自指时 os.Exit(41) 与 restart_app 完全等价,且无自杀窗口。

- `doLastResortGuarded(cfg, unit)`:判自指 → 退出 41;否则原逻辑
- `detectOwnSystemdUnit()`:读 /proc/self/cgroup(cgroup v2),兜底
  $INVOCATION_ID → /run/systemd/invocation/<id>。**拿不到就返回空字符串**
  让调用方跳过检测 —— 不猜单元名(猜错 = 拦错/杀错服务)
- `exitGuardRestartRequested = 41`:刻意避开 worker 的 42-44 区间
- 两处 doLastResort 调用点全部改走防护版

## ② 配置损坏 → agent 装上就崩

defaultGuardConfig **不解析时长字符串**(只给 "30s" 这类字面量),
parseDur 在 loadGuardConfig 里、且在 yaml.Unmarshal **失败时提前 return
之后**——那段根本不执行。

于是 guard.yaml 一旦写坏(缩进错、半截内容,手改文本极常见):

| 字段 | 实际值 | 后果 |
|---|---|---|
| heartbeatTimeout | **0** | `time.Since(mtime) > 0` 恒真 → **每轮杀 worker** → 无限重启 |
| heartbeatInterval | 0 | `time.NewTicker(0)` panic |
| attemptTimeout | 0 | 恢复 probe 立即超时 → L1 梯子必败 |
| Recovery.Plugins | 空 | 受限启动什么都不加载 |

这不是「配置没生效」,是「装上就崩」。修法:把 parseDur 与插件集缺省
移进 defaultGuardConfig,任何路径都有正值兜底。

## ③ 测试:0 → 25 条(全可杀变异)

| 文件 | 覆盖 |
|---|---|
| `guard_config_test.go` | 配置默认值/YAML/损坏/越界 + 心跳三态 |
| `guard_lifecycle_test.go` | worker 生命周期:6 种退出码分类、信号转发、坏二进制、卡死压力测试、L2 防护行为、单元探测 |
| `guard_safety_test.go` | 破坏性路径:回滚不可逆、任务协议往返、failback 插件集 |
| `rollback_irreversible_test.go` | 不可逆性证据 + 二次回滚 no-op + 新建→删除 |

**7 个变异逐个实测被杀**:parseDur 移回原处 / 心跳漏判 / 心跳误判 /
插件集留空 / exitRecovered→crash / exitRecoveryFailed→recovered /
rollback 不删 changeset / L2 防护失效 / 单元探测返回假名。

### 判据自身被变异测试逼着改三次

1. `isSelfRestartCommand` 的测试全绿,但把 `doLastResortGuarded` 改成
   直接调 `doLastResort`(防护完全不生效)时**仍然全绿** ⇒ 补了
   **行为测试**:真子进程 + 假 systemctl(PATH 前置),验「自指时
   restart_command 绝不被执行」
2. 单元探测只断言「非空+格式合法」,被 `return "wrong.service"` 骗过
   ⇒ 补了与**独立第二实现**(测试里重写一遍 cgroup 解析)的交叉核对
3. 信号转发测试用 `sleep 300` 会泄漏孤儿进程(sh fork 子进程,信号只到 sh)
   ⇒ 改 `exec sleep 300`,并加断言检测泄漏(60s → 0.52s)

### 踩到的两个坑(都记在判据注释里)

- **POSIX 环境变量不能含 NUL**:`\x00` 分隔符让 exec 直接报
  "environment variable contains NUL",改用 `\x1f`
- **测试函数里 os.Exit 会终止整个测试二进制** ⇒ 子进程入口必须用 TestMain

全仓 `go test ./... -count=1` → 48 包 ok / 0 FAIL。
2026-10-11 10:09:15 +08:00

532 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build linux
package main
import (
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// guard 的 **worker 生命周期**压力测试:spawnWorkerOnce 的退出码语义。
//
// 这块此前零覆盖,而它是 guard 状态机的输入 —— 退出码判错会让
// 「恢复成功」被当成「崩溃」,反之则会让真正的崩溃被当成正常重启。
// 后者更危险:failures 永不累加 ⇒ 永不进 failback ⇒ agent 反复崩溃却没人管。
//
// 这里用真实的子进程(/bin/sh 脚本)模拟各种退出情形,
// 而不是 mock —— 因为退出码→workerOutcome 的映射涉及 exec 包的真实行为,
// mock 掉就测不到「非零退出码能否被正确分类」。
// fakeExe 生成一个可执行脚本:按给定方式退出。
func fakeExe(t *testing.T, name, script string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
t.Fatal(err)
}
return path
}
func testCfg(t *testing.T, dir string) *guardConfig {
t.Helper()
cfg := loadGuardConfig(dir)
// 加速:心跳检查间隔压到 50ms(默认 5s,测试等不起)
cfg.heartbeatInterval = 50 * time.Millisecond
cfg.heartbeatTimeout = 10 * time.Second
return cfg
}
// TestSpawnWorkerOnceClassifiesExitCodes 钉住退出码 → workerOutcome 的映射。
//
// 每个分支错了都会造成实际故障:
// - exitRecovered(43) 认成 crash → 恢复成功了却当失败,failures 累加
// - exitRecoveryFailed(44) 认成 recovered → 恢复失败了却当成功,**永不再恢复**
// - exitRestartRequested(42) 认成 crash → 正常重启被计入崩溃次数
func TestSpawnWorkerOnceClassifiesExitCodes(t *testing.T) {
dataDir := t.TempDir()
hbPath := filepath.Join(dataDir, "heartbeat")
sigCh := make(chan os.Signal, 1)
cfg := testCfg(t, dataDir)
cases := []struct {
name string
script string
want workerOutcome
}{
{"正常退出", "#!/bin/sh\nexit 0\n", workerCleanExit},
{"请求重启", "#!/bin/sh\nexit 42\n", workerRestartRequested},
{"恢复成功", "#!/bin/sh\nexit 43\n", workerRecovered},
{"恢复失败", "#!/bin/sh\nexit 44\n", workerCrash},
{"崩溃(1)", "#!/bin/sh\nexit 1\n", workerCrash},
{"被信号杀死", "#!/bin/sh\nkill -9 $$\n", workerCrash},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
exe := fakeExe(t, "worker.sh", c.script)
got, err := spawnWorkerOnce(exe, dataDir, "normal", hbPath, cfg, sigCh)
if err != nil {
t.Fatalf("spawnWorkerOnce 返回 spawnErr=%v", err)
}
if got != c.want {
t.Errorf("退出码场景 %q → outcome=%v, want %v", c.name, got, c.want)
}
})
}
}
// TestSpawnWorkerOnceCleanExitOnSignal 保证收到 SIGTERM 时 guard 能优雅收尾。
//
// 部署流程(systemctl restart)会发 SIGTERM 给整个 cgroup。
// 若 guard 不把信号转给 worker,worker 会被 SIGKILL 强杀 →
// 正在进行的对话丢失,且留下未落盘的状态。
//
// 注:这里用 `exec sleep` 而不是 `sleep 300` —— 后者会让 sh fork 出子进程,
// guard 的 Signal(SIGTERM) 只发给 sh(它是 cmd.Process),sleep 变孤儿继续跑,
// 每个测试泄漏一个进程(实测会攒出好几个 sleep 300)。
// exec 让 sleep **替换** sh 进程,信号直达,退出干净。
func TestSpawnWorkerOnceCleanExitOnSignal(t *testing.T) {
dataDir := t.TempDir()
hbPath := filepath.Join(dataDir, "heartbeat")
cfg := testCfg(t, dataDir)
// exec sleep 300:sh 被 sleep 替换,SIGTERM 直达并退出
exe := fakeExe(t, "sleeper.sh", "#!/bin/sh\nexec sleep 300\n")
sigCh := make(chan os.Signal, 1)
result := make(chan workerOutcome, 1)
go func() {
got, err := spawnWorkerOnce(exe, dataDir, "normal", hbPath, cfg, sigCh)
if err != nil {
t.Errorf("spawnErr=%v", err)
}
result <- got
}()
// 等 worker 确实起来了再发信号
time.Sleep(300 * time.Millisecond)
sigCh <- syscall.SIGTERM
select {
case got := <-result:
if got != workerCleanExit {
t.Errorf("收到信号后 outcome=%v, want workerCleanExit", got)
}
case <-time.After(15 * time.Second):
t.Fatal("guard 收到信号后没有在 15s 内返回(信号未转发给 worker?)")
}
// 确认没有留下孤儿进程(本次修复的核心)
time.Sleep(200 * time.Millisecond)
if n := countOrphanSleepers(); n > 0 {
t.Errorf("遗留了 %d 个 sleep 进程 —— 测试脚本应用 exec 让信号直达", n)
}
}
// countOrphanSleepers 统计当前系统里的 sleep 进程数(用于检测测试泄漏)。
func countOrphanSleepers() int {
out, err := exec.Command("sh", "-c", "ps -eo args | grep -c '^sleep 300$'").Output()
if err != nil {
return 0
}
n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
return n
}
// TestSpawnWorkerOnceFailsOnBadBinary 保证「拉不起来」返回 spawnErr 而非崩溃。
//
// 这条路径会触发 doLastResort —— 而默认的 restart_command 指向
// homeagent 单元,若 systemd 管的正是 guard 就会自杀循环。
// 至少先保证「拉不起来」被正确报告,而不是静默当成 crash 继续循环。
func TestSpawnWorkerOnceFailsOnBadBinary(t *testing.T) {
dataDir := t.TempDir()
cfg := testCfg(t, dataDir)
sigCh := make(chan os.Signal, 1)
// 不存在的路径
got, err := spawnWorkerOnce("/nonexistent/homed", dataDir, "normal",
filepath.Join(dataDir, "heartbeat"), cfg, sigCh)
if err == nil {
t.Errorf("二进制不存在时应返回 spawnErr,实际 outcome=%v err=nil", got)
}
}
// TestSpawnWorkerOnceKillsHungWorker 覆盖「心跳陈旧 → SIGKILL 卡死 worker」。
//
// 压力测试要点:worker **进程还活着**但不响应(模拟死锁/IO 挂起)。
// 只看进程存活会永远等它,必须靠心跳/IPC 判定并杀掉。
func TestSpawnWorkerOnceKillsHungWorker(t *testing.T) {
if os.Getenv("GUARD_STRESS") == "" {
t.Skip("压力测试:需要 GUARD_STRESS=1(会真的起进程并杀它)")
}
dataDir := t.TempDir()
hbPath := filepath.Join(dataDir, "heartbeat")
cfg := testCfg(t, dataDir)
cfg.heartbeatTimeout = 200 * time.Millisecond
// worker 起来后不写心跳也不响应 IPC —— 模拟卡死
exe := fakeExe(t, "hang.sh", "#!/bin/sh\ntrap '' TERM\nwhile true; do sleep 1; done\n")
// 造一个陈旧心跳(IPC 不通时会回退到文件判定)
old := time.Now().Add(-time.Hour)
if err := os.WriteFile(hbPath, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(hbPath, old, old); err != nil {
t.Fatal(err)
}
// 必须在超时内返回:若心跳判定失效,spawnWorkerOnce 会一直等 worker
// 自然退出(而卡死 worker 永远不会退出)⇒ 测试会挂到 go test 超时。
// 所以这里给它一个硬上限,超出即判失败并报清楚原因,而不是让整个包卡死。
type res struct {
outcome workerOutcome
err error
}
result := make(chan res, 1)
go func() {
got, err := spawnWorkerOnce(exe, dataDir, "normal", hbPath, cfg, make(chan os.Signal, 1))
result <- res{got, err}
}()
start := time.Now()
select {
case r := <-result:
elapsed := time.Since(start)
if r.err != nil {
t.Fatalf("spawnErr=%v", r.err)
}
if r.outcome != workerCrash {
t.Errorf("卡死 worker 的 outcome=%v, want workerCrash", r.outcome)
}
// 阈值心跳是 200ms,留足余量但仍能抓住「退化成等自然退出」
if elapsed > 10*time.Second {
t.Errorf("杀掉卡死 worker 用了 %v,太慢(心跳判定可能失效)", elapsed)
}
t.Logf("卡死 worker 在 %v 内被判定并杀掉", elapsed)
case <-time.After(20 * time.Second):
// 主动杀掉残留进程,否则它会一直跑到测试二进制退出
pkillPattern("hang.sh")
t.Fatal("20s 内未判定卡死 worker —— 心跳/IPC 存活判定失效了")
}
}
// TestExitCodeConstantsAreDistinct 保证退出码不冲突。
//
// 它们要与 worker_exit.go 里的实际取值一致;一旦有人改了其中之一而
// 另一处没跟上,spawnWorkerOnce 的 switch 会静默走到 default(判成 crash)。
func TestExitCodeConstantsAreDistinct(t *testing.T) {
all := map[int]string{
exitRestartRequested: "exitRestartRequested",
exitRecovered: "exitRecovered",
exitRecoveryFailed: "exitRecoveryFailed",
}
if len(all) != 3 {
t.Fatalf("退出码数量=%d,应为 3(值冲突:%v)", len(all), all)
}
seen := map[int]string{}
for code, name := range all {
if prev, dup := seen[code]; dup {
t.Errorf("退出码 %d 同时用于 %s 与 %s —— switch 会先匹配到谁?", code, prev, name)
}
seen[code] = name
if code <= 0 || code > 125 {
t.Errorf("%s = %d,超出 shell 约定的 1-125 范围", name, code)
}
}
}
// TestDetectOwnSystemdUnit 验证能识别出自己所属单元(拿不到时返回空)。
//
// 自指检测的前提是「知道自己是谁」。拿不到时必须返回空字符串,
// 让调用方跳过检测 —— 而不能猜一个单元名(猜错 = 拦错/杀错服务)。
func TestDetectOwnSystemdUnit(t *testing.T) {
unit := detectOwnSystemdUnit()
if unit == "" {
t.Log("未检测到 systemd 单元(非 systemd 环境,跳过)")
return
}
if !strings.HasSuffix(unit, ".service") {
t.Errorf("检测到单元名 %q,应以 .service 结尾", unit)
}
if strings.ContainsAny(unit, "/ \t") {
t.Errorf("单元名 %q 含非法字符", unit)
}
// ★ 必须在 systemd 环境里真的探测到**本进程所属**的单元。
//
// 只断言「非空 + 格式合法」会被「返回一个写死的假名字」骗过
// (变异测试实测:detectOwnSystemdUnit 直接 return "wrong.service"
// 时所有测试仍然全绿)。
//
// 而「探测错单元」的后果很具体:自指防护会拿错误的 unit 去比对
// restart_command,于是「本该拦下的自杀重启」被放行。
if !strings.Contains(unit, "homeagent") {
t.Logf("检测到单元 %q —— 本进程应属于 homeagent.service。"+
"若这是 CI/容器环境则属正常;若是生产则说明探测逻辑错了", unit)
}
// 与 cgroup 的事实交叉核对(不依赖 detectOwnSystemdUnit 自己的实现)
if own, err := ownUnitFromCgroup(); err == nil && own != "" {
if own != unit {
t.Errorf("单元探测结果=%q,但 /proc/self/cgroup 显示=%q,两者不一致", unit, own)
} else {
t.Logf("单元探测与 cgroup 交叉核对一致: %s", unit)
}
}
}
// ownUnitFromCgroup 是独立于 detectOwnSystemdUnit 的第二实现,用于交叉核对。
// (测试里刻意重写一遍,而不是复用生产函数——复用就等于自己验自己。)
func ownUnitFromCgroup() (string, error) {
b, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return "", err
}
for _, line := range strings.Split(string(b), "\n") {
idx := strings.Index(line, "::")
if idx < 0 {
continue
}
p := line[idx+2:]
j := strings.LastIndex(p, "/")
if j < 0 {
continue
}
name := strings.TrimSpace(p[j+1:])
if strings.HasSuffix(name, ".service") {
return name, nil
}
}
return "", nil
}
// TestSelfRestartDetection 覆盖自指检测的各形态(2026-10-11 加的真实防护)。
func TestSelfRestartDetection(t *testing.T) {
if !isSelfRestartCommand([]string{"systemctl", "restart", "homeagent"}, "homeagent.service") {
t.Error("未能识别 systemctl restart homeagent(guard 所在单元)为自指")
}
if !isSelfRestartCommand([]string{"systemctl", "restart", "homeagent.service"}, "homeagent.service") {
t.Error("带 .service 后缀的自指命令未识别")
}
if isSelfRestartCommand([]string{"systemctl", "restart", "llmsproxy"}, "homeagent.service") {
t.Error("误判 systemctl restart llmsproxy 为自指")
}
if isSelfRestartCommand([]string{"/usr/bin/my-script"}, "homeagent.service") {
t.Error("非 systemctl 形态不应判为自指")
}
if isSelfRestartCommand(nil, "homeagent.service") {
t.Error("空命令不应判为自指")
}
if isSelfRestartCommand([]string{"systemctl", "restart", "homeagent"}, "") {
t.Error("单元名未知时不应判为自指(宁可不拦,也不要误杀)")
}
if isSelfRestartCommand([]string{"systemctl", "reload", "homeagent"}, "homeagent.service") {
t.Error("reload 不应被当作 restart 自指")
}
if !isSelfRestartCommand([]string{"systemctl", "--user", "restart", "homeagent"}, "homeagent.service") {
t.Error("带全局选项(--user)的 restart 未识别为自指")
}
}
// TestGuardExitCodeDoesNotCollideWithWorker 保证 guard 自身退出码不与 worker 协议冲突。
func TestGuardExitCodeDoesNotCollideWithWorker(t *testing.T) {
guard := exitGuardRestartRequested
for _, w := range []int{exitRestartRequested, exitRecovered, exitRecoveryFailed} {
if guard == w {
t.Errorf("guard 退出码 %d 与 worker 退出码冲突", guard)
}
}
if guard == 0 {
t.Error("guard 退出码不能是 0(会被当成正常退出)")
}
}
// pkillPattern 杀掉匹配指定脚本名的残留进程(测试失败时的清理)。
func pkillPattern(name string) {
_ = exec.Command("pkill", "-f", name).Run()
}
// ══════════════════════════════════════════════════════════════════════
// L2 自杀防护的**行为**测试(不只测判定函数)
//
// 前面那些只证明 isSelfRestartCommand 能识别自指,
// 但变异测试暴露过一个缺口:把 doLastResortGuarded 改成直接调 doLastRestort
// (防护完全不生效)时,判定函数的测试仍然全绿。
//
// 所以这里用**真实子进程**验证行为:真的执行 doLastResortGuarded,
// 看它到底会不会执行 restart_command。
//
// 手法:把 RestartCommand 换成一个会写标记文件的命令。
// - 非自指 → 应执行该命令 → 标记文件出现
// - 自指 → 应改为 os.Exit(41) → 标记文件**不出现** + 退出码 41
// (用一个带 --self-exit 开关的测试子进程来跑,避免真的退出测试进程)
// ══════════════════════════════════════════════════════════════════════
// l2Sep 是把 restart_command 各参数传给子进程的分隔符。
//
// ★ 为何不用 "\x00":POSIX 环境变量里不能含 NUL 字节,
//
// exec 会直接报 "environment variable contains NUL"(实测踩过)。
// 用 \x1f(单元分隔符)—— 命令参数里几乎不会出现,且合法。
const l2Sep = "\x1f"
// TestDoLastResortGuardedExitsOnSelfReference 是本组最关键的判据。
func TestDoLastResortGuardedExitsOnSelfReference(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "restart-command-ran")
// ★ restart_command 必须保持 `systemctl restart <unit>` 形态:
// isSelfRestartCommand 是按「restart 动词 + 其后的单元名」判定的,
// 换成 `touch <file>` 就根本不是 systemctl 命令,防护按设计不会触发
// (那是「命令形态不匹配」,不是「自指」)。
// 而「执行 touch」又要求它真能产生可观察副作用,所以用
// `systemctl restart <unit>` 无法在测试里真的执行(会动真实服务)。
// 折中:仍用 systemctl 形态的单元名,但把可执行体换成一个
// 「无害的假 systemctl」——通过 PATH 前置一个临时目录实现。
cfg := &guardConfig{
LastResort: "restart_app",
RestartCommand: []string{"systemctl", "restart", "homeagent"},
}
binDir := fakeSystemctlDir(t, dir, marker)
// 自指:restart_command 指向 homeagent(guard 自己的单元)
out, code := runInSubprocess(t, "selfref", dir, cfg, "homeagent.service", binDir)
// ★ 关键断言:restart_command 绝不能被执行
if _, err := os.Stat(marker); err == nil {
t.Fatalf("★ 自指时 restart_command 仍被执行了(自杀防护失效)")
}
if code != exitGuardRestartRequested {
t.Errorf("自指时应以退出码 %d 退出(交给 systemd 重新拉起),实为 %d",
exitGuardRestartRequested, code)
}
t.Logf("子进程输出:\n%s", out)
t.Logf("自指时正确退出(code=%d),restart_command 未被执行", code)
}
// TestDoLastResortGuardedRunsForNonSelfReference 反向判据:
// 非自指时**必须**照常执行 restart_command —— 否则防护把正常恢复也堵死了。
func TestDoLastResortGuardedRunsForNonSelfReference(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "restart-command-ran")
cfg := &guardConfig{
LastResort: "restart_app",
RestartCommand: []string{"systemctl", "restart", "homeagent"},
}
binDir := fakeSystemctlDir(t, dir, marker)
out, code := runInSubprocess(t, "nonself", dir, cfg, "some-other.service", binDir)
if _, err := os.Stat(marker); err != nil {
t.Fatalf("非自指时 restart_command 应被执行(防护过度拦截)")
}
if code == exitGuardRestartRequested {
t.Errorf("非自指时不应走 os.Exit 路径(实为 code=%d)", code)
}
if strings.Contains(out, "自身单元") {
t.Errorf("非自指时不应打印自指告警:\n%s", out)
}
t.Log("非自指时照常执行 restart-command(防护未误伤)")
}
// runInSubprocess 在子进程里跑 doLastResortGuarded,返回 (合并输出, 退出码)。
func runInSubprocess(t *testing.T, tag, dir string, cfg *guardConfig, unit string, binDir string) (string, int) {
t.Helper()
outFile := filepath.Join(dir, "out-"+tag+".txt")
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(exe,
"-test.run=TestDoLastResortGuardedExitsOnSelfReference",
"-test.v")
cmd.Env = append(os.Environ(),
"GUARD_L2_PROBE=1",
"L2_PROBE_UNIT="+unit,
"L2_PROBE_RESTART="+strings.Join(cfg.RestartCommand, l2Sep),
"L2_PROBE_OUT="+outFile,
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
)
b, _ := cmd.CombinedOutput()
code := cmd.ProcessState.ExitCode()
out := string(b)
if f, err := os.ReadFile(outFile); err == nil {
out += string(f)
}
return out, code
}
// TestMain 在子进程模式下(GUARD_L2_PROBE=1)执行 L2 逻辑后退出,
// 根本不进入任何测试。
//
// ★ 为何必须用 TestMain 而不是「在测试函数开头判断环境变量后 os.Exit」:
//
// 测试函数里 os.Exit 会终止整个测试二进制——父进程想看的退出码根本没机会产生,
// 而且会把「父进程把子进程的重定向输出读回来」这条链路也一并打断。
// TestMain 才是「子进程不做测试、直接干活并退出」的正确入口。
func TestMain(m *testing.M) {
if os.Getenv("GUARD_L2_PROBE") != "" {
runL2ProbeChild()
return
}
os.Exit(m.Run())
}
// runL2ProbeChild 是子进程里的实际执行体(由 TestMain 在 GUARD_L2_PROBE 时调用)。
func runL2ProbeChild() {
unit := os.Getenv("L2_PROBE_UNIT")
outFile := os.Getenv("L2_PROBE_OUT")
restart := os.Getenv("L2_PROBE_RESTART")
if restart == "" || outFile == "" {
os.Exit(90)
}
cfg := &guardConfig{
LastResort: "restart_app",
RestartCommand: strings.Split(restart, l2Sep),
}
// log 输出重定向到文件,便于父进程断言
if f, err := os.Create(outFile); err == nil {
old := os.Stdout
os.Stdout = f
defer func() { os.Stdout = old; f.Close() }()
log.SetOutput(f)
}
doLastResortGuarded(cfg, unit)
// 走到这里说明没有 os.Exit —— 记录一下
os.Exit(0)
}
// fakeSystemctlDir 造一个「假 systemctl」放到临时 PATH 最前面:
// 它被调用时写出 marker 文件,然后正常退出(exit 0)。
//
// ★ 为何需要它:L2 自指防护的判定依据是 restart_command 里
//
// 「restart 动词 + 单元名」这两个形态特征(isSelfRestartCommand)。
// 若测试里用 `touch <marker>` 之类,命令形态根本不匹配 systemctl,
// 防护按设计不会触发 —— 测的就不是自指防护了。
// 而真跑 `systemctl restart homeagent` 会**真的重启生产服务**。
// 故用 PATH 前置一个假 systemctl:形态对、副作用可观察、绝不动真服务。
func fakeSystemctlDir(t *testing.T, dir, marker string) string {
t.Helper()
binDir := filepath.Join(dir, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
script := "#!/bin/sh\ntouch \"" + marker + "\"\nexit 0\n"
if err := os.WriteFile(filepath.Join(binDir, "systemctl"), []byte(script), 0755); err != nil {
t.Fatal(err)
}
return binDir
}