修复Linux安装器停服查询失败放行并补齐22场景回归

This commit is contained in:
xuans 2026-09-17 15:32:06 +08:00
parent f04f873232
commit 6b6fc12686
5 changed files with 115 additions and 14 deletions

View File

@ -45,8 +45,10 @@ func TestLinuxInstallerPolicy(t *testing.T) {
`load_existing_certificate_dir`,
`certificate_dir_explicit=true`,
`file -b -- "$source_dir/$artifact"`,
`systemctl is-active --quiet Scert.service`,
`systemctl is-active --quiet Scert-admin.service`,
`for unit in Scert.service Scert-admin.service`,
`systemctl show "$unit" -p LoadState -p ActiveState -p SubState -p MainPID -p ControlPID -p Job`,
`loaded:inactive:dead|loaded:failed:failed|not-found:inactive:dead`,
`"$main_pid" == "0" && "$control_pid" == "0" && -z "$job"`,
`pwd -P`,
`10-certificate-path.conf`,
`"$INSTALL_DIR/conf"`,

View File

@ -264,12 +264,31 @@ finish_install_transaction() {
}
ensure_services_stopped() {
if systemctl is-active --quiet Scert.service; then
fail "升级或安装前必须停止 Scert.service"
fi
if systemctl is-active --quiet Scert-admin.service; then
fail "升级或安装前必须停止 Scert-admin.service"
fi
local unit properties key value
local load_state active_state sub_state main_pid control_pid job
for unit in Scert.service Scert-admin.service; do
if ! properties="$(systemctl show "$unit" -p LoadState -p ActiveState -p SubState -p MainPID -p ControlPID -p Job)"; then
fail "无法查询 $unit 的停服状态,拒绝安装"
fi
load_state="" active_state="" sub_state="" main_pid="" control_pid="" job="unknown"
while IFS='=' read -r key value; do
case "$key" in
LoadState) load_state="$value" ;;
ActiveState) active_state="$value" ;;
SubState) sub_state="$value" ;;
MainPID) main_pid="$value" ;;
ControlPID) control_pid="$value" ;;
Job) job="$value" ;;
esac
done <<< "$properties"
# 查询失败、过渡状态或排队任务均不能作为已停服的证据。
case "$load_state:$active_state:$sub_state" in
loaded:inactive:dead|loaded:failed:failed|not-found:inactive:dead) ;;
*) fail "升级或安装前必须确认 $unit 已停止,当前状态:$load_state/$active_state/$sub_state" ;;
esac
[[ "$main_pid" == "0" && "$control_pid" == "0" && -z "$job" ]] || \
fail "$unit 仍有进程、排队任务或缺失状态,拒绝安装"
done
}
ensure_service_account() {

View File

@ -10,6 +10,32 @@ import sys
import tempfile
SERVICE_STATES = {
"service-active": ("loaded", "active", "running", "51", "0", ""),
"service-activating": ("loaded", "activating", "start", "0", "52", "7"),
"service-deactivating": ("loaded", "deactivating", "stop-sigterm", "51", "0", "8"),
"service-reloading": ("loaded", "reloading", "reload", "51", "52", "9"),
"service-failed-process": ("loaded", "failed", "failed", "51", "0", ""),
"service-pending-job": ("loaded", "inactive", "dead", "0", "0", "10"),
"service-masked": ("masked", "inactive", "dead", "0", "0", ""),
"service-not-found": ("not-found", "inactive", "dead", "0", "0", ""),
"service-failed-stopped": ("loaded", "failed", "failed", "0", "0", ""),
}
SERVICE_QUERY_SCENARIOS = [
"service-query-failure", "service-empty-output", "service-missing-property",
"admin-deactivating", "admin-query-failure",
]
SERVICE_GUARDS = [
name for name in SERVICE_STATES
if name not in ["service-not-found", "service-failed-stopped"]
] + SERVICE_QUERY_SCENARIOS
SUCCESS_SCENARIOS = ["success", "fresh-success", "service-not-found", "service-failed-stopped"]
SCENARIOS = [
"web-link", "binary-directory", "unit-failure", "reload-failure",
"rollback-reload-failure", "success", "fresh-failure", "fresh-success",
] + list(SERVICE_STATES) + SERVICE_QUERY_SCENARIOS
def run(*args, **kwargs):
return subprocess.run(args, check=True, text=True, **kwargs)
@ -71,11 +97,28 @@ def prepare(base, scenario, certificate_root):
command_log = str(base / "systemctl.log")
counter = str(base / "reload-count")
systemctl = base / "commands/systemctl"
properties = ["LoadState", "ActiveState", "SubState", "MainPID", "ControlPID", "Job"]
states = SERVICE_STATES.get(scenario, ("loaded", "inactive", "dead", "0", "0", ""))
if scenario == "admin-deactivating":
states = SERVICE_STATES["service-deactivating"]
state_output = "\n".join(key + "=" + value for key, value in zip(properties, states))
if scenario == "service-missing-property":
state_output = "\n".join(state_output.splitlines()[:-1])
service_name = "Scert-admin.service" if scenario.startswith("admin-") else "Scert.service"
state_command = "printf '%s\\n' '" + state_output + "'; exit 0\n"
if scenario in ["service-query-failure", "admin-query-failure"]:
state_command = "exit 1\n"
if scenario == "service-empty-output":
state_command = "exit 0\n"
systemctl.write_text(
'#!/usr/bin/env bash\nset -eu\n'
f'printf "%s\\n" "$*" >> "{command_log}"\n'
'if [[ "$1" == "is-active" ]]; then exit 3; fi\n'
'[[ "$*" == "daemon-reload" ]] || exit 97\n'
'if [[ "$1" == "is-active" ]]; then '
+ ('exit 0; fi\n' if scenario == "service-active" else 'exit 3; fi\n')
+ 'if [[ "$1" == "show" ]]; then\n'
+ f'if [[ "$2" == "{service_name}" ]]; then\n' + state_command + 'fi\n'
+ "printf '%s\\n' 'LoadState=loaded' 'ActiveState=inactive' 'SubState=dead' 'MainPID=0' 'ControlPID=0' 'Job='; exit 0\nfi\n"
+ '[[ "$*" == "daemon-reload" ]] || exit 97\n'
+ (f'if [[ ! -e "{counter}" ]]; then touch "{counter}"; exit 42; fi\n' if scenario == "reload-failure" else "")
+ ('exit 42\n' if scenario == "rollback-reload-failure" else "")
)
@ -115,7 +158,7 @@ def parent(args):
source = Path(args.source).resolve(strict=True)
failures = []
for scenario in args.scenarios.split(","):
if scenario not in ["web-link", "binary-directory", "unit-failure", "reload-failure", "rollback-reload-failure", "success", "fresh-failure", "fresh-success"]:
if scenario not in SCENARIOS:
raise RuntimeError("未知测试场景")
base = Path(tempfile.mkdtemp(prefix=scenario + "-", dir=output))
prepare(base, scenario, args.certificate_root)
@ -130,13 +173,15 @@ def parent(args):
"--source", str(source), "--child", str(base), "--certificate-root", args.certificate_root)
code = json.loads((base / "result.json").read_text())["exit_code"]
errors = []
if (code == 0) != (scenario in ["success", "fresh-success"]):
if (code == 0) != (scenario in SUCCESS_SCENARIOS):
errors.append("退出码不符合预期")
if {name: digest(base / "install" / name) for name in protected} != before_protected:
errors.append("业务文件被修改")
if snapshot(base / "certificates") != before_certificates:
errors.append("证书文件被修改")
if scenario in ["success", "fresh-success"]:
if scenario in SERVICE_GUARDS and snapshot(base / "install") != before_install:
errors.append("停服检查拒绝前已修改安装目录")
if scenario in SUCCESS_SCENARIOS:
for name in ["scert", "scert-admin", "web/index.html"]:
if digest(base / "install" / name) != digest(source / name):
errors.append("安装文件内容不正确:" + name)
@ -168,8 +213,11 @@ def parent(args):
command_log = base / "systemctl.log"
calls = command_log.read_text().splitlines() if command_log.exists() else []
allowed = {"is-active --quiet Scert.service", "is-active --quiet Scert-admin.service", "daemon-reload"}
allowed.update("show " + name + " -p LoadState -p ActiveState -p SubState -p MainPID -p ControlPID -p Job" for name in ["Scert.service", "Scert-admin.service"])
if any(call not in allowed for call in calls):
errors.append("出现不允许的服务操作")
if scenario in SERVICE_GUARDS and "daemon-reload" in calls:
errors.append("停服检查失败仍加载服务配置")
report = {"scenario": scenario, "exit_code": code, "errors": errors, "artifacts": str(base)}
(base / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2))
print(json.dumps(report, ensure_ascii=False), flush=True)
@ -183,7 +231,7 @@ parser.add_argument("--source", required=True)
parser.add_argument("--output")
parser.add_argument("--child")
parser.add_argument("--certificate-root", required=True)
parser.add_argument("--scenarios", default="web-link,binary-directory,unit-failure,reload-failure,rollback-reload-failure,success,fresh-failure,fresh-success")
parser.add_argument("--scenarios", default=",".join(SCENARIOS))
arguments = parser.parse_args()
if arguments.child:
child(arguments)

View File

@ -182,3 +182,31 @@
- [✓] 安装器缺陷复现、修复、新发布包八场景复测及受限候选协议检查。
- [✓] 本地全量测试、race、vet、模块完整性及漏洞调用路径扫描。
- [!] 真实 systemd 升级与业务回退、DNS 签发、完整初始化、正式切流和目标机推送仍未完成。继续外部签发需要用户明确授权测试域名及 DNS 凭据范围,当前不可宣称具备正式切流条件。
## 安装前停服状态核验补充(2026-09-17)
### 现场恢复与需求
- [✓] 原工作目录 `D:/Desktop/SSL` 已不存在;当前项目在 `D:/Desktop/Scert`,起始提交仍为 `cf1fe15`,已跟踪文件无改动。未移动、删除或覆盖既有未跟踪产物。
- [✓] 修改前创建并推送中文检查点 `f04f873`。
- [✓] 管理机只读复核:正式 Scert PID `530794`、重启次数零;MySQL PID `549584`、重启次数一;候选服务停止;正式程序摘要与前轮一致。
- [!] 正式 `/readyz` 仍为 HTTP 503、`setup_incomplete`。管理机 443 由 Smail 占用,最终入口方案需要协调,不可直接占用或停止该服务。
### 缺陷与复现
旧安装器将 `systemctl is-active` 的所有非零退出都当成已停服;不能区分过渡状态和查询故障。使用旧包 `8c3d878` 在私有挂载命名空间注入启动中、停止中、主服务查询失败和管理服务查询失败,四个场景都错误返回零并替换隔离目录中的发布文件。
首次测试因产物目录不存在在预检阶段退出,没有执行安装器;创建全新产物目录后完成上述四场景复现。证据目录 `/var/tmp/scert-install-state-before-20260917-1530`,测试整体退出码一。路径中的时间后缀仅为标识,不代表实际执行时刻。
### 修复与验证
安装器改为显式检查六个 systemd 属性,未知状态和查询失败均拒绝。回归覆盖原八场景以及 active、activating、deactivating、reloading、failed 但仍有进程、排队任务、masked、首次不存在、failed 且已停服、查询失败、空输出、缺失字段和管理服务拒绝分支,共 22 场景。
- [✓] 本地 `go test -count=1 ./internal/admin` 通过,`git diff --check` 通过。
- [✓] 使用旧发布文件的独立副本加上新安装器,Linux 22 场景全部通过;退出码零,运行约 33.6 秒。
- [✓] 拒绝路径对整个安装目录快照(内容、类型、权限、所有者)不产生变化,unit 和证书保持不变,未执行 daemon-reload。
- [✓] 其余场景继续验证首次安装、旧资源退出生效目录、失败发布文件恢复、回退失败提示以及业务文件保护。
产物 `/var/tmp/scert-install-state-after-20260917-1535`。测试通过 systemd 临时单元限制内存 96 MiB、无 Swap、CPU 25%、32 任务、240 秒;systemctl 在安装器命名空间内仍为状态/故障注入桩。宿主 systemd 仅管理测试资源,正式 Scert、MySQL 和 Smail 不停止、不重启。
这证明安装器的状态分支和文件行为,不证明完整真实 systemd 升级、业务回退或最终发布包已验收;最终包验证需单独补记。未使用 DNS、ACME 或业务数据库。

View File

@ -139,6 +139,10 @@ sudo journalctl -u Scert -b -n 30 --no-pager
安装器禁止在 `Scert.service` 或 `Scert-admin.service` 运行时覆盖程序。升级顺序:
停服核验使用 `systemctl show` 同时读取加载状态、活动状态、子状态、主进程、控制进程和排队任务。仅允许已加载的 `inactive/dead`、无进程的 `failed/failed`,以及首次安装的 `not-found/inactive/dead`;主进程与控制进程必须都为零,任务必须为空。查询失败、字段缺失、启动中、停止中、重载中或 masked 状态均在写入前拒绝。失败状态获准替换文件不代表原故障已解决,启动前仍需检查退出原因。
这是安装前的状态检查,不是与 systemd 的原子互斥。维护窗口内不得有其他管理员、定时任务或自动化工具并发启动服务;安装器不能防止检查完成后外部又启动服务。
```bash
sudo systemctl stop Scert-admin
sudo systemctl stop Scert