修复Linux安装器停服查询失败放行并补齐22场景回归
This commit is contained in:
parent
f04f873232
commit
6b6fc12686
@ -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"`,
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 或业务数据库。
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user