"""在私有挂载命名空间中验证固定路径安装器,不操作正式服务。""" import argparse import hashlib import json import os from pathlib import Path import subprocess 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) def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() def snapshot(root): result = {} for path in sorted(root.rglob("*")): info = path.lstat() relative = str(path.relative_to(root)) if path.is_symlink(): result[relative] = ["link", os.readlink(path), info.st_uid, info.st_gid] elif path.is_file(): result[relative] = [digest(path), info.st_mode & 0o777, info.st_uid, info.st_gid] else: result[relative] = ["directory", info.st_mode & 0o777, info.st_uid, info.st_gid] return result def child(args): base = Path(args.child).resolve(strict=True) marker = base / "namespace-marker" if marker.read_text() != "scert-installer-test\n": raise RuntimeError("隔离目录标识不正确") source = base / "source" run("mount", "--bind", str(Path(args.source).resolve(strict=True)), str(source)) run("mount", "-o", "remount,bind,ro", str(source)) for name, target in [("install", "/scert"), ("units", "/etc/systemd/system"), ("certificates", args.certificate_root)]: run("mount", "--bind", str(base / name), target) if os.stat(base / name).st_ino != os.stat(target).st_ino: raise RuntimeError("隔离挂载未生效") environment = dict(os.environ, PATH=str(base / "commands") + ":/usr/sbin:/usr/bin:/sbin:/bin") command = ["bash", str(source / "install-linux.sh"), "--source-dir", str(source)] if not (base / "install/conf/installation.json").exists(): command.extend(["--certificate-dir", args.certificate_root]) result = subprocess.run( command, env=environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) (base / "output.txt").write_text(result.stdout) (base / "result.json").write_text(json.dumps({"exit_code": result.returncode})) def prepare(base, scenario, certificate_root): for name in ["install/conf", "install/data", "install/log", "install/backups", "install/docs", "install/web/assets", "units/Scert.service.d", "certificates", "commands", "source"]: (base / name).mkdir(parents=True, exist_ok=True) (base / "namespace-marker").write_text("scert-installer-test\n") for name in ["scert", "scert-admin", "docs/Scert-service-management.md", "docs/Scert-admin-service-management.md", "web/index.html", "web/assets/old.js"]: (base / "install" / name).write_text("old:" + name) (base / "install/conf/installation.json").write_text(json.dumps({"certificate_root": certificate_root}) + "\n") for name in ["conf/scert.json", "conf/master.key", "conf/secrets.json", "data/scert.db", "backups/keep"]: (base / "install" / name).write_text("preserve:" + name) (base / "certificates/keep.pem").write_text("preserve:certificate") for name in ["Scert.service", "Scert-admin.service", "Scert.service.d/10-certificate-path.conf"]: (base / "units" / name).write_text("old:" + name) 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 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 "") ) systemctl.chmod(0o755) if scenario == "web-link": (base / "install/web/assets/link").symlink_to(base / "certificates/keep.pem") if scenario == "binary-directory": (base / "install/scert").rename(base / "old-scert") (base / "install/scert").mkdir() if scenario in ["unit-failure", "fresh-failure"]: command = base / "commands/install" command.write_text( '#!/usr/bin/env bash\nset -eu\n' 'if [[ "${@: -1}" == "/etc/systemd/system/Scert-admin.service" ]]; then exit 43; fi\n' 'exec /usr/bin/install "$@"\n' ) command.chmod(0o755) if scenario in ["fresh-failure", "fresh-success"]: (base / "install").rename(base / "unused-install-fixture") (base / "install").mkdir() (base / "units").rename(base / "unused-units-fixture") (base / "units").mkdir() def parent(args): if sys.platform != "linux" or os.geteuid() != 0: raise RuntimeError("测试需要 Linux root 和私有挂载命名空间") for target in ["/scert", args.certificate_root, "/etc/systemd/system"]: if not Path(target).is_dir() or Path(target).is_symlink(): raise RuntimeError("测试挂载目标必须是既有普通目录") run("getent", "passwd", "Scert", stdout=subprocess.DEVNULL) run("unshare", "--mount", "--propagation", "private", "true") output = Path(args.output).resolve(strict=True) for target in [Path("/scert"), Path(args.certificate_root), Path("/etc/systemd/system")]: if output == target or target in output.parents: raise RuntimeError("测试产物不能位于被隔离的挂载点内") source = Path(args.source).resolve(strict=True) failures = [] for scenario in args.scenarios.split(","): if scenario not in SCENARIOS: raise RuntimeError("未知测试场景") base = Path(tempfile.mkdtemp(prefix=scenario + "-", dir=output)) prepare(base, scenario, args.certificate_root) protected = ["conf/scert.json", "conf/master.key", "conf/secrets.json", "data/scert.db", "backups/keep"] if scenario.startswith("fresh-"): protected = [] before_protected = {name: digest(base / "install" / name) for name in protected} before_install = snapshot(base / "install") before_units = snapshot(base / "units") before_certificates = snapshot(base / "certificates") run("unshare", "--mount", "--propagation", "private", sys.executable, str(Path(__file__).resolve()), "--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_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 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) if (base / "install/web/assets/old.js").exists(): errors.append("旧前端资源仍在生效目录") else: after = snapshot(base / "install") for name, expected in before_install.items(): # 安装器会统一业务目录权限,仅比较发布文件与安装记录。 if name in ["scert", "scert-admin", "conf/installation.json"] or name.startswith(("web/", "docs/")): if after.get(name) != expected: errors.append("失败后发布文件未保留:" + name) if scenario == "fresh-failure": for name in ["scert", "scert-admin", "web", "conf/installation.json"]: if (base / "install" / name).exists(): errors.append("首次安装失败仍残留生效文件:" + name) if any(path.is_file() for path in (base / "units").rglob("*")): errors.append("首次安装失败仍残留服务文件") elif snapshot(base / "units") != before_units: errors.append("失败后服务文件未恢复") for path in (base / "install").glob(".install-*"): if path.stat().st_mode & 0o777 != 0o700 or path.stat().st_uid != 0: errors.append("升级副本目录权限不正确") text = (base / "output.txt").read_text() if scenario == "rollback-reload-failure" and "安装回退不完整,禁止启动服务" not in text: errors.append("回退失败缺少明确提示") if scenario in ["unit-failure", "reload-failure", "fresh-failure"] and "已恢复升级前发布文件" not in text: errors.append("缺少回退完成记录") 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) failures.extend(errors) if failures: raise SystemExit(1) parser = argparse.ArgumentParser() 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=",".join(SCENARIOS)) arguments = parser.parse_args() if arguments.child: child(arguments) else: parent(arguments)