diff --git a/backend/internal/admin/installer_test.go b/backend/internal/admin/installer_test.go index d58ff98..f1740a2 100644 --- a/backend/internal/admin/installer_test.go +++ b/backend/internal/admin/installer_test.go @@ -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"`, diff --git a/deploy/install-linux.sh b/deploy/install-linux.sh index 893506c..eeaee50 100644 --- a/deploy/install-linux.sh +++ b/deploy/install-linux.sh @@ -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() { diff --git a/deploy/tests/install-linux-test.py b/deploy/tests/install-linux-test.py index cd7a9c6..9517e92 100644 --- a/deploy/tests/install-linux-test.py +++ b/deploy/tests/install-linux-test.py @@ -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) diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 0fa8818..84e7ac6 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -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 或业务数据库。 diff --git a/doc/生产部署与运维说明.md b/doc/生产部署与运维说明.md index f668c89..361fc83 100644 --- a/doc/生产部署与运维说明.md +++ b/doc/生产部署与运维说明.md @@ -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