Commit abfb3cb5b0a8297927523626eea12d2ca4256289
1 parent
753fefa4
U-036: real unittest coverage for supervisor/isolation + Deploy Supervisor skeleton
Ports graphloop's test_isolated_coding_workspace.py/test_workspace_security.py/ test_change_manifest.py/test_selective_apply.py into supervisor/isolation/ (only the import path changed -- the graphlemon modules were already adapted verbatim from graphloop, confirmed API-identical) plus a few extra edge cases the originals didn't cover. 52 tests green, replacing what had been a single manual smoke test since these modules were staged. Adds supervisor/deploy_supervisor.py: process lifecycle only (PID file, atomic status file, start/stop/is_running), no deploy decision logic yet -- that's U-037+'s job, injected via the poll_fn hook run_foreground() calls on a schedule. PID/status conventions adapted (not imported) from graphloop's hermes_watchdog.py atomic-write pattern. Fail-closed: any exit path (normal, SIGTERM, or an exception from poll_fn) unconditionally writes status='stopped' and removes the PID file, so is_running() never lies. RealSubprocessLifecycleTest actually spawns and stops a real `python3 -m supervisor.deploy_supervisor` process rather than mocking it, per this Unit's own "能独立启停" Done criterion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
9 changed files
with
1111 additions
and
1 deletions
docs/development-plan.md
| ... | ... | @@ -354,6 +354,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 354 | 354 | - 依赖:无(代码已存在,是补测试和补骨架,不是从零开发) |
| 355 | 355 | - Done:四模块各自有对应 `test_*.py`(可直接照搬/改造 graphloop 的 `test_change_manifest.py`/`test_selective_apply.py`/`test_workspace_security.py` 等,逻辑未改动,测试也基本不用改);Deploy Supervisor 骨架能独立启停,不受 Live/Candidate 进程崩溃影响 |
| 356 | 356 | - **待定(2026-07-31 订正)**:此前这里写的是"`supervisor/` 目录该归入 U-002 的目标项目树(`.claude/`、`skills/`、`src/` 那套)还是 U-003 的平台目录"——但 U-002 的范围已经订正为只创建 `.graphlemon/`(见该 Unit 2026-07-31 订正说明),`.claude/`、`skills/` 从来就不是目标项目的东西,是 graphlemon 平台自己仓库的资产。所以这个选择题已经不成立,`supervisor/` 只可能归属"platform 自身工具"(当前的实际存放位置,`supervisor/isolation/`),不存在真正的"归入目标项目"选项;保留这条记录是为了让后来者理解为什么这里曾经有过一次误判 |
| 357 | + - **实现补充(2026-08-02)**:四模块的单测直接照搬 graphloop 对应文件(`test_isolated_coding_workspace.py`/`test_workspace_security.py`/`test_change_manifest.py`/`test_selective_apply.py`),逐条核对后确认 API 与行为完全匹配(graphlemon 这四个模块本来就是"adapted verbatim"),只改了 import 路径(`from supervisor.isolation import X`)+ 补了几条 graphlemon 自己模块文档里提到但原测试没覆盖的小 case(`isolated_workspace` 的空/不存在 source_dir 拒绝、`workspace_security` 的换行符注入拒绝);新增 `supervisor/__init__.py`(原本靠 Python 3 namespace package 也能跑,但补一个空的显式 `__init__.py` 和 `src/__init__.py` 保持同一约定,不依赖隐式行为)。52 个测试全绿,放在 `supervisor/isolation/test_*.py`(和被测模块同目录,比照 `src/test_*.py` 的既有约定),运行方式 `python3 -m unittest discover -s supervisor/isolation -p "test_*.py"`(不在 `python3 -m unittest discover -s src` 覆盖范围内,是新增的第二条独立测试入口,README/CLAUDE.md 后续需要记一笔)。 | |
| 358 | + Deploy Supervisor 骨架落在新文件 `supervisor/deploy_supervisor.py`:PID 文件+状态文件走 `paths.platform_home()/deploy_supervisor/` 下(复用 U-003 已有的运行时目录约定,不新建一套),原子写沿用 `tempfile+os.replace`(参照 graphloop `hermes_watchdog.py` 的 `_atomic_save`,未直接 import,逻辑重写了一遍)。`run_foreground(poll_fn, poll_interval, iterations)` 是骨架的核心循环——`poll_fn` 是 U-037+ 才会真正实现的"检查是否有待部署的 commit 并行动"逻辑,本 Unit 只负责按周期调用它、写 PID/状态文件、处理 `SIGTERM`(收到后跳出循环,`finally` 块无条件写 `status='stopped'` 并删 PID 文件,包括 `poll_fn` 自己抛异常的情况——fail-closed,崩溃了就该看起来像崩溃了,不能让 PID 文件继续指向一个已经不存在的循环)。`start_detached()`/`stop()` 是给上层(未来某个"启动 graphlemon 平台"脚本,或人工)用的真实进程管理入口,`start_detached` 用 `subprocess.Popen` 拉起真实独立进程(不是线程,故意如此——两个进程之间不共享失败域是这整个设计的前提),`stop()` 发 `SIGTERM` 并轮询等它自己清理干净,超时不 SIGKILL(留给调用方判断,不擅自做主)。**验证方式**:`RunForegroundTest` 系列在同一个测试进程内直接验证循环/状态写入/`SIGTERM`处理(`SIGTERM` 测试用 `poll_fn` 内部对自己进程 `os.kill(os.getpid(), SIGTERM)` 模拟外部信号,不需要真的起第二个进程);`RealSubprocessLifecycleTest` 是真实的(不 mock)`python3 -m supervisor.deploy_supervisor` 子进程拉起+`stop()`往返,确认"能独立启停"这条 Done 标准不是靠单元测试逻辑自证的,是真的对着一个真实操作系统进程验证过的。全程用 `GRAPHLEMON_HOME` 环境变量覆盖到临时目录,不碰真实用户的 `~/.graphlemon`。73 个测试(含四个 isolation 模块)全绿。 | |
| 359 | + - 覆盖:`supervisor/isolation/` 四模块(真实单测覆盖,不再只是"一次性手工冒烟");Deploy Supervisor 进程骨架(`supervisor/deploy_supervisor.py`,尚不含任何真实部署决策逻辑) | |
| 357 | 360 | |
| 358 | 361 | - **U-037 [Risk Gate]** `c_deploy_supervisor_stage`:候选实例拉起(全新目录 checkout,不 `git pull` 原地改)+ 一次性 DB 副本机制(候选实例验证期间绝不接触生产库) |
| 359 | 362 | - 依赖:U-034, U-036 | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -53,7 +53,7 @@ |
| 53 | 53 | |
| 54 | 54 | {"id": "U-034", "phase": "PH5", "sub_phase": "PH5-S1", "name": "01-M 扩展:deployment/deploy_health_signal + core_engine 枚举(见 development-plan.md 2026-08-02 实现补充)", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["deployment", "deploy_health_signal", "bug_record"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "两张新表建表成功(共 17 张);bug_record 新枚举值向后兼容 U-015/U-018 已有测试;deployment 状态机用 01-M 1.15 完整 15 值枚举;ux_deployment_active 表达式索引强制全局单例非终态约束;bug_record.py 应用层白名单刻意未跟着放开 core_engine,classification+路由逻辑留给 U-035"}, |
| 55 | 55 | {"id": "U-035", "phase": "PH5", "sub_phase": "PH5-S1", "name": "self-target worktree 无条件强制 + core_engine 分类审计(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-013", "U-016", "U-034"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-03"]}, "done": "self-target 时无条件走 worktree(work_graph_orchestrate.run_graph 内强制);core_engine 分类(src/core_engine_classify.py,路径规则)在 self-target 任务 PASS 收尾时记一条审计用 bug_record;且改动落地后重跑 U-013/REQ-03(worktree 隔离)现有回归用例全部通过。**2026-08-02 用户决策修正**:Deploy Supervisor 触发条件按 supervisor-auto-rollback-design.md §1 字面设计(self-target+PASS 即触发,bug_record 是旁路产物不是前提),而非 03-C/04-P 字面文本要求的\"先有 core_engine bug_record\"——后者会让从不产生 bug_record 的 goal 型自我改进任务永远触发不了 Deploy Supervisor,已用 AskUserQuestion 与用户确认此订正。c_org_loop_route 未改动(fast_lane/full_replan 是 FAIL 路径重试路由,与此处 PASS 路径的蓝绿触发正交),deployment 行创建仍是 U-037 的范围,不在本 Unit 提前做"}, |
| 56 | - {"id": "U-036", "phase": "PH5", "sub_phase": "PH5-S2", "name": "supervisor/isolation 补齐单测 + Deploy Supervisor 进程骨架", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "四模块各有 test_*.py;Deploy Supervisor 骨架独立进程可启停,不受 Live/Candidate 崩溃影响"}, | |
| 56 | + {"id": "U-036", "phase": "PH5", "sub_phase": "PH5-S2", "name": "supervisor/isolation 补齐单测 + Deploy Supervisor 进程骨架(见 development-plan.md 2026-08-02 实现补充)", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "四模块各有 test_*.py(供 python3 -m unittest discover -s supervisor/isolation 运行,52 测试全绿);Deploy Supervisor 骨架(supervisor/deploy_supervisor.py)独立进程可启停(真实子进程验证,非 mock),不受 Live/Candidate 崩溃影响;本 Unit 只做进程生命周期骨架,不含任何真实部署决策逻辑(U-037+ 的 poll_fn 挂载点)"}, | |
| 57 | 57 | {"id": "U-037", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_stage", "classification": "Risk Gate", "depends_on": ["U-034", "U-036"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_stage"], "process": [], "requirement": []}, "done": "能生成 deployment 记录并拉起监听临时端口、连一次性 DB 副本的候选进程"}, |
| 58 | 58 | {"id": "U-038", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_health_check", "classification": "Risk Gate", "depends_on": ["U-037"], "coverage": {"model": [], "view": [], "controller": ["c_deploy_supervisor_health_check"], "process": [], "requirement": []}, "done": "process_alive/http_health/canary_task 三项检查全过 -> health_check_passed;任一失败 -> health_check_failed 且 Live 未受影响"}, |
| 59 | 59 | {"id": "U-039", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_cutover", "classification": "Risk Gate", "depends_on": ["U-038"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_cutover"], "process": [], "requirement": []}, "done": "健康检查通过后完成真实切换;在途 work_node_execution 重启后可恢复;previous_instance_dir 保留至监控窗口结束"}, | ... | ... |
supervisor/__init__.py
0 → 100644
supervisor/deploy_supervisor.py
0 → 100644
| 1 | +"""Deploy Supervisor process skeleton (U-036). | |
| 2 | + | |
| 3 | +Independent OS process (started via `python3 -m supervisor.deploy_supervisor`), | |
| 4 | +pure stdlib, no LLM CLI dependency -- per docs/supervisor-auto-rollback-design.md | |
| 5 | +§2's "three independent processes, no shared failure domain" principle: | |
| 6 | +Deploy Supervisor crashing must not affect Live/Candidate, and Live/Candidate | |
| 7 | +crashing must not affect Deploy Supervisor's own ability to keep observing | |
| 8 | +and act. | |
| 9 | + | |
| 10 | +PID/status file conventions adapted (NOT imported -- see CLAUDE.md's "do not | |
| 11 | +import graphloop code directly" rule) from graphloop's hermes_watchdog.py: | |
| 12 | +atomic writes (tempfile-in-same-dir + os.replace), liveness via | |
| 13 | +`os.kill(pid, 0)`, fail-closed (this skeleton never auto-restarts anything -- | |
| 14 | +a crashing `poll_fn` stops the loop and marks status='stopped' rather than | |
| 15 | +silently dying while still holding the PID file, which would make | |
| 16 | +`is_running()` lie). | |
| 17 | + | |
| 18 | +Scope: this Unit owns process lifecycle only (start/stop/status/PID file). | |
| 19 | +The actual "check for staged deployments and act" logic is U-037+'s job, | |
| 20 | +injected here as the `poll_fn` callback -- U-036 has no opinion about what | |
| 21 | +that logic does, only about running it on a schedule and surviving/reporting | |
| 22 | +its own crashes correctly. | |
| 23 | +""" | |
| 24 | + | |
| 25 | +import argparse | |
| 26 | +import json | |
| 27 | +import os | |
| 28 | +import signal | |
| 29 | +import subprocess | |
| 30 | +import sys | |
| 31 | +import time | |
| 32 | + | |
| 33 | +from src import paths | |
| 34 | + | |
| 35 | + | |
| 36 | +def runtime_dir(): | |
| 37 | + d = os.path.join(paths.platform_home(), "deploy_supervisor") | |
| 38 | + os.makedirs(d, exist_ok=True) | |
| 39 | + return d | |
| 40 | + | |
| 41 | + | |
| 42 | +def pid_file_path(): | |
| 43 | + return os.path.join(runtime_dir(), "supervisor.pid") | |
| 44 | + | |
| 45 | + | |
| 46 | +def status_file_path(): | |
| 47 | + return os.path.join(runtime_dir(), "supervisor.status.json") | |
| 48 | + | |
| 49 | + | |
| 50 | +def _atomic_write_json(path, data): | |
| 51 | + tmp = path + ".{}.tmp".format(os.getpid()) | |
| 52 | + with open(tmp, "w", encoding="utf-8") as handle: | |
| 53 | + json.dump(data, handle) | |
| 54 | + os.replace(tmp, path) | |
| 55 | + | |
| 56 | + | |
| 57 | +def is_alive(pid): | |
| 58 | + if not isinstance(pid, int) or pid <= 0: | |
| 59 | + return False | |
| 60 | + try: | |
| 61 | + os.kill(pid, 0) | |
| 62 | + except OSError: | |
| 63 | + return False | |
| 64 | + return True | |
| 65 | + | |
| 66 | + | |
| 67 | +def write_status(status, detail=None): | |
| 68 | + _atomic_write_json(status_file_path(), { | |
| 69 | + "status": status, "pid": os.getpid(), "updated_at": time.time(), "detail": detail, | |
| 70 | + }) | |
| 71 | + | |
| 72 | + | |
| 73 | +def read_status(): | |
| 74 | + try: | |
| 75 | + with open(status_file_path(), encoding="utf-8") as handle: | |
| 76 | + return json.load(handle) | |
| 77 | + except (OSError, json.JSONDecodeError): | |
| 78 | + return None | |
| 79 | + | |
| 80 | + | |
| 81 | +def is_running(): | |
| 82 | + """True if a Deploy Supervisor process is currently alive per the PID | |
| 83 | + file. Doesn't distinguish "never started" from "PID file stale" -- | |
| 84 | + callers needing that distinction should also check `read_status()`.""" | |
| 85 | + try: | |
| 86 | + with open(pid_file_path(), encoding="utf-8") as handle: | |
| 87 | + pid = int(handle.read().strip()) | |
| 88 | + except (OSError, ValueError): | |
| 89 | + return False | |
| 90 | + return is_alive(pid) | |
| 91 | + | |
| 92 | + | |
| 93 | +class _StopRequested(Exception): | |
| 94 | + """Internal: raised by the SIGTERM handler to break run_foreground's loop.""" | |
| 95 | + | |
| 96 | + | |
| 97 | +def run_foreground(poll_fn=None, poll_interval=5.0, iterations=None): | |
| 98 | + """Blocking main loop -- calls `poll_fn()` (if given) once per | |
| 99 | + `poll_interval` seconds, up to `iterations` cycles (None = forever, only | |
| 100 | + exits on SIGTERM). Writes a PID file + 'running' status on entry, and | |
| 101 | + unconditionally writes a 'stopped' status + removes the PID file on any | |
| 102 | + exit path (normal completion, SIGTERM, or an unhandled exception from | |
| 103 | + `poll_fn`) -- an exception from `poll_fn` propagates to the caller after | |
| 104 | + cleanup runs, it is not swallowed (fail-closed: a broken poll cycle | |
| 105 | + should surface, not vanish into an infinite silent retry). | |
| 106 | + """ | |
| 107 | + def _handle_sigterm(signum, frame): | |
| 108 | + raise _StopRequested() | |
| 109 | + | |
| 110 | + previous_handler = signal.signal(signal.SIGTERM, _handle_sigterm) | |
| 111 | + with open(pid_file_path(), "w", encoding="utf-8") as handle: | |
| 112 | + handle.write(str(os.getpid())) | |
| 113 | + write_status("running") | |
| 114 | + try: | |
| 115 | + count = 0 | |
| 116 | + while iterations is None or count < iterations: | |
| 117 | + if poll_fn is not None: | |
| 118 | + poll_fn() | |
| 119 | + count += 1 | |
| 120 | + if iterations is not None and count >= iterations: | |
| 121 | + break | |
| 122 | + time.sleep(poll_interval) | |
| 123 | + except _StopRequested: | |
| 124 | + pass | |
| 125 | + finally: | |
| 126 | + signal.signal(signal.SIGTERM, previous_handler) | |
| 127 | + write_status("stopped") | |
| 128 | + if os.path.exists(pid_file_path()): | |
| 129 | + os.remove(pid_file_path()) | |
| 130 | + | |
| 131 | + | |
| 132 | +def start_detached(poll_interval=5.0): | |
| 133 | + """Launches `python3 -m supervisor.deploy_supervisor` as a real, | |
| 134 | + independent OS process (Popen, not a thread) -- crashing does not take | |
| 135 | + down whatever called this. Returns the child's pid. Raises RuntimeError | |
| 136 | + if a Deploy Supervisor is already running (per its own PID file).""" | |
| 137 | + if is_running(): | |
| 138 | + raise RuntimeError("Deploy Supervisor is already running") | |
| 139 | + process = subprocess.Popen( | |
| 140 | + [sys.executable, "-m", "supervisor.deploy_supervisor", "--poll-interval", str(poll_interval)], | |
| 141 | + cwd=paths.repo_root(), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, | |
| 142 | + ) | |
| 143 | + return process.pid | |
| 144 | + | |
| 145 | + | |
| 146 | +def stop(timeout=10.0): | |
| 147 | + """Sends SIGTERM to the running Deploy Supervisor (if any) and waits up | |
| 148 | + to `timeout` seconds for it to exit cleanly (PID file removed). Returns | |
| 149 | + True if it stopped, False if it was already not running or didn't stop | |
| 150 | + in time -- fail-closed: this does not SIGKILL on timeout, that's a | |
| 151 | + judgment call left to whoever's driving it, not silently forced here.""" | |
| 152 | + try: | |
| 153 | + with open(pid_file_path(), encoding="utf-8") as handle: | |
| 154 | + pid = int(handle.read().strip()) | |
| 155 | + except (OSError, ValueError): | |
| 156 | + return False | |
| 157 | + if not is_alive(pid): | |
| 158 | + return False | |
| 159 | + os.kill(pid, signal.SIGTERM) | |
| 160 | + deadline = time.time() + timeout | |
| 161 | + while time.time() < deadline: | |
| 162 | + if not is_running(): | |
| 163 | + return True | |
| 164 | + time.sleep(0.1) | |
| 165 | + return False | |
| 166 | + | |
| 167 | + | |
| 168 | +def main(): | |
| 169 | + parser = argparse.ArgumentParser() | |
| 170 | + parser.add_argument("--poll-interval", type=float, default=5.0) | |
| 171 | + args = parser.parse_args() | |
| 172 | + run_foreground(poll_interval=args.poll_interval) | |
| 173 | + | |
| 174 | + | |
| 175 | +if __name__ == "__main__": | |
| 176 | + main() | ... | ... |
supervisor/isolation/test_change_manifest.py
0 → 100644
| 1 | +"""U-036: real unittest coverage for change_manifest.py -- adapted from | |
| 2 | +graphloop's test_change_manifest.py (2026-08-02). Module logic is unchanged | |
| 3 | +from graphloop ("adapted verbatim", see change_manifest.py's own docstring) | |
| 4 | +except graphlemon's `unified_diff` collapses "@@ -l,s +l,s @@" hunk headers | |
| 5 | +to a single leading "@@" marker (see that module's own comment) -- the | |
| 6 | +"only text gets hunks" assertion below counts "@@" substring occurrences, | |
| 7 | +which still holds 1-to-1 with graphlemon's collapsed format for these | |
| 8 | +single-hunk fixtures. | |
| 9 | + | |
| 10 | +Run: python3 -m unittest discover -s supervisor/isolation -p "test_*.py" -v | |
| 11 | +""" | |
| 12 | +import hashlib | |
| 13 | +import os | |
| 14 | +import shutil | |
| 15 | +import tempfile | |
| 16 | +import unittest | |
| 17 | + | |
| 18 | +from supervisor.isolation import change_manifest as cm | |
| 19 | + | |
| 20 | +TEXT_A = "hello\n" | |
| 21 | +TEXT_A_MODIFIED = "hello modified\n" | |
| 22 | +BINARY_BYTES = b"\x89PNG\r\n\x1a\n\x00\x01\x02\x03BINARYDATA\xff\xfe" | |
| 23 | + | |
| 24 | + | |
| 25 | +def sha256_of(data): | |
| 26 | + return hashlib.sha256(data).hexdigest() | |
| 27 | + | |
| 28 | + | |
| 29 | +class ChangeManifestTestCase(unittest.TestCase): | |
| 30 | + def setUp(self): | |
| 31 | + self.source_dir = tempfile.mkdtemp(prefix="cm-src-") | |
| 32 | + self.addCleanup(shutil.rmtree, self.source_dir, ignore_errors=True) | |
| 33 | + self._extra_dirs = [] | |
| 34 | + self.addCleanup(self._cleanup_extra_dirs) | |
| 35 | + | |
| 36 | + def _cleanup_extra_dirs(self): | |
| 37 | + for path in self._extra_dirs: | |
| 38 | + shutil.rmtree(path, ignore_errors=True) | |
| 39 | + | |
| 40 | + def _new_isolated_dir(self): | |
| 41 | + path = tempfile.mkdtemp(prefix="cm-iso-") | |
| 42 | + self._extra_dirs.append(path) | |
| 43 | + return path | |
| 44 | + | |
| 45 | + @staticmethod | |
| 46 | + def _write_text(root, rel_path, content): | |
| 47 | + full = os.path.join(root, *rel_path.split("/")) | |
| 48 | + os.makedirs(os.path.dirname(full), exist_ok=True) | |
| 49 | + with open(full, "w", encoding="utf-8") as handle: | |
| 50 | + handle.write(content) | |
| 51 | + return full | |
| 52 | + | |
| 53 | + @staticmethod | |
| 54 | + def _write_bytes(root, rel_path, data): | |
| 55 | + full = os.path.join(root, *rel_path.split("/")) | |
| 56 | + os.makedirs(os.path.dirname(full), exist_ok=True) | |
| 57 | + with open(full, "wb") as handle: | |
| 58 | + handle.write(data) | |
| 59 | + return full | |
| 60 | + | |
| 61 | + def _copy_source_to_isolated(self): | |
| 62 | + isolated_dir = self._new_isolated_dir() | |
| 63 | + shutil.rmtree(isolated_dir) | |
| 64 | + shutil.copytree(self.source_dir, isolated_dir) | |
| 65 | + return isolated_dir | |
| 66 | + | |
| 67 | + | |
| 68 | +class SnapshotWorkspaceTests(ChangeManifestTestCase): | |
| 69 | + def test_records_text_file_with_hash_size_and_kind(self): | |
| 70 | + self._write_text(self.source_dir, "a.txt", TEXT_A) | |
| 71 | + | |
| 72 | + snapshot = cm.snapshot_workspace(self.source_dir) | |
| 73 | + | |
| 74 | + entry = snapshot["files"]["a.txt"] | |
| 75 | + self.assertEqual(entry["sha256"], sha256_of(TEXT_A.encode("utf-8"))) | |
| 76 | + self.assertEqual(entry["size"], len(TEXT_A.encode("utf-8"))) | |
| 77 | + self.assertEqual(entry["kind"], "text") | |
| 78 | + | |
| 79 | + def test_records_binary_file_as_binary_kind(self): | |
| 80 | + self._write_bytes(self.source_dir, "logo.bin", BINARY_BYTES) | |
| 81 | + | |
| 82 | + snapshot = cm.snapshot_workspace(self.source_dir) | |
| 83 | + | |
| 84 | + entry = snapshot["files"]["logo.bin"] | |
| 85 | + self.assertEqual(entry["sha256"], sha256_of(BINARY_BYTES)) | |
| 86 | + self.assertEqual(entry["size"], len(BINARY_BYTES)) | |
| 87 | + self.assertEqual(entry["kind"], "binary") | |
| 88 | + | |
| 89 | + def test_uses_relative_posix_paths_for_nested_files(self): | |
| 90 | + self._write_text(self.source_dir, "subdir/nested/file.txt", "x") | |
| 91 | + | |
| 92 | + snapshot = cm.snapshot_workspace(self.source_dir) | |
| 93 | + | |
| 94 | + self.assertIn("subdir/nested/file.txt", snapshot["files"]) | |
| 95 | + | |
| 96 | + def test_ignores_git_pycache_pytest_cache_and_dsstore(self): | |
| 97 | + self._write_text(self.source_dir, "keep.txt", "keep") | |
| 98 | + self._write_text(self.source_dir, ".git/config", "git config") | |
| 99 | + self._write_text(self.source_dir, ".git/objects/ab/cdef", "blob") | |
| 100 | + self._write_text(self.source_dir, "__pycache__/mod.cpython-39.pyc", "pyc") | |
| 101 | + self._write_text(self.source_dir, ".pytest_cache/v/cache/lastfailed", "{}") | |
| 102 | + self._write_text(self.source_dir, ".DS_Store", "junk") | |
| 103 | + | |
| 104 | + snapshot = cm.snapshot_workspace(self.source_dir) | |
| 105 | + | |
| 106 | + self.assertEqual(set(snapshot["files"]), {"keep.txt"}) | |
| 107 | + | |
| 108 | + def test_skips_symlinks_but_keeps_their_real_target(self): | |
| 109 | + target = self._write_text(self.source_dir, "real.txt", "real content") | |
| 110 | + link_path = os.path.join(self.source_dir, "link.txt") | |
| 111 | + os.symlink(target, link_path) | |
| 112 | + | |
| 113 | + snapshot = cm.snapshot_workspace(self.source_dir) | |
| 114 | + | |
| 115 | + self.assertIn("real.txt", snapshot["files"]) | |
| 116 | + self.assertNotIn("link.txt", snapshot["files"]) | |
| 117 | + | |
| 118 | + | |
| 119 | +class BuildChangeManifestTests(ChangeManifestTestCase): | |
| 120 | + def _baseline_snapshot(self): | |
| 121 | + self._write_text(self.source_dir, "a.txt", TEXT_A) | |
| 122 | + self._write_text(self.source_dir, "b.txt", "world\n") | |
| 123 | + self._write_text(self.source_dir, "c.txt", "keep me\n") | |
| 124 | + return cm.snapshot_workspace(self.source_dir) | |
| 125 | + | |
| 126 | + def test_added_modified_deleted_and_unchanged_classification(self): | |
| 127 | + snapshot = self._baseline_snapshot() | |
| 128 | + isolated_dir = self._copy_source_to_isolated() | |
| 129 | + | |
| 130 | + self._write_text(isolated_dir, "a.txt", TEXT_A_MODIFIED) | |
| 131 | + os.remove(os.path.join(isolated_dir, "b.txt")) | |
| 132 | + self._write_text(isolated_dir, "d.txt", "brand new\n") | |
| 133 | + | |
| 134 | + manifest = cm.build_change_manifest(snapshot, isolated_dir) | |
| 135 | + by_path = {entry["path"]: entry for entry in manifest["changed_files"]} | |
| 136 | + | |
| 137 | + self.assertEqual(set(by_path), {"a.txt", "b.txt", "d.txt"}) | |
| 138 | + self.assertNotIn("c.txt", by_path) | |
| 139 | + | |
| 140 | + modified = by_path["a.txt"] | |
| 141 | + self.assertEqual(modified["status"], "modified") | |
| 142 | + self.assertEqual(modified["old_hash"], sha256_of(TEXT_A.encode("utf-8"))) | |
| 143 | + self.assertEqual(modified["new_hash"], sha256_of(TEXT_A_MODIFIED.encode("utf-8"))) | |
| 144 | + | |
| 145 | + deleted = by_path["b.txt"] | |
| 146 | + self.assertEqual(deleted["status"], "deleted") | |
| 147 | + self.assertIsNone(deleted["new_hash"]) | |
| 148 | + self.assertEqual(deleted["old_hash"], sha256_of(b"world\n")) | |
| 149 | + | |
| 150 | + added = by_path["d.txt"] | |
| 151 | + self.assertEqual(added["status"], "added") | |
| 152 | + self.assertIsNone(added["old_hash"]) | |
| 153 | + self.assertEqual(added["new_hash"], sha256_of(b"brand new\n")) | |
| 154 | + | |
| 155 | + def test_unchanged_files_never_appear_in_changed_files(self): | |
| 156 | + snapshot = self._baseline_snapshot() | |
| 157 | + isolated_dir = self._copy_source_to_isolated() | |
| 158 | + | |
| 159 | + manifest = cm.build_change_manifest(snapshot, isolated_dir) | |
| 160 | + | |
| 161 | + self.assertEqual(manifest["changed_files"], []) | |
| 162 | + self.assertEqual(cm.unified_diff(manifest), "") | |
| 163 | + | |
| 164 | + def test_ignored_files_excluded_even_when_added_in_isolated(self): | |
| 165 | + snapshot = self._baseline_snapshot() | |
| 166 | + isolated_dir = self._copy_source_to_isolated() | |
| 167 | + | |
| 168 | + self._write_text(isolated_dir, ".git/config", "new git config") | |
| 169 | + self._write_text(isolated_dir, "__pycache__/mod.cpython-39.pyc", "pyc") | |
| 170 | + self._write_text(isolated_dir, ".DS_Store", "junk") | |
| 171 | + | |
| 172 | + manifest = cm.build_change_manifest(snapshot, isolated_dir) | |
| 173 | + | |
| 174 | + self.assertEqual(manifest["changed_files"], []) | |
| 175 | + | |
| 176 | + def test_binary_file_added_is_classified_binary(self): | |
| 177 | + snapshot = self._baseline_snapshot() | |
| 178 | + isolated_dir = self._copy_source_to_isolated() | |
| 179 | + self._write_bytes(isolated_dir, "logo.bin", BINARY_BYTES) | |
| 180 | + | |
| 181 | + manifest = cm.build_change_manifest(snapshot, isolated_dir) | |
| 182 | + | |
| 183 | + self.assertEqual(len(manifest["changed_files"]), 1) | |
| 184 | + entry = manifest["changed_files"][0] | |
| 185 | + self.assertEqual(entry["path"], "logo.bin") | |
| 186 | + self.assertEqual(entry["status"], "added") | |
| 187 | + self.assertEqual(entry["kind"], "binary") | |
| 188 | + self.assertEqual(entry["new_hash"], sha256_of(BINARY_BYTES)) | |
| 189 | + | |
| 190 | + def test_changed_files_are_sorted_deterministically(self): | |
| 191 | + snapshot = self._baseline_snapshot() | |
| 192 | + isolated_dir = self._copy_source_to_isolated() | |
| 193 | + | |
| 194 | + self._write_text(isolated_dir, "z_file.txt", "z\n") | |
| 195 | + self._write_text(isolated_dir, "0_file.txt", "0\n") | |
| 196 | + self._write_text(isolated_dir, "m_file.txt", "m\n") | |
| 197 | + | |
| 198 | + manifest = cm.build_change_manifest(snapshot, isolated_dir) | |
| 199 | + paths = [entry["path"] for entry in manifest["changed_files"]] | |
| 200 | + | |
| 201 | + self.assertEqual(paths, sorted(paths)) | |
| 202 | + self.assertEqual(paths, ["0_file.txt", "m_file.txt", "z_file.txt"]) | |
| 203 | + | |
| 204 | + def test_rejects_symlink_found_in_isolated_workspace(self): | |
| 205 | + snapshot = self._baseline_snapshot() | |
| 206 | + isolated_dir = self._copy_source_to_isolated() | |
| 207 | + target = os.path.join(isolated_dir, "a.txt") | |
| 208 | + os.symlink(target, os.path.join(isolated_dir, "sneaky_link.txt")) | |
| 209 | + | |
| 210 | + with self.assertRaises(cm.ChangeManifestError): | |
| 211 | + cm.build_change_manifest(snapshot, isolated_dir) | |
| 212 | + | |
| 213 | + def test_rejects_symlink_escaping_isolated_workspace(self): | |
| 214 | + snapshot = self._baseline_snapshot() | |
| 215 | + isolated_dir = self._copy_source_to_isolated() | |
| 216 | + outside_dir = self._new_isolated_dir() | |
| 217 | + outside_file = self._write_text(outside_dir, "outside.txt", "outside") | |
| 218 | + os.symlink(outside_file, os.path.join(isolated_dir, "escape_link.txt")) | |
| 219 | + | |
| 220 | + with self.assertRaises(cm.ChangeManifestError): | |
| 221 | + cm.build_change_manifest(snapshot, isolated_dir) | |
| 222 | + | |
| 223 | + def test_rejects_tampered_snapshot_with_traversal_path(self): | |
| 224 | + snapshot = self._baseline_snapshot() | |
| 225 | + isolated_dir = self._copy_source_to_isolated() | |
| 226 | + | |
| 227 | + for bad_path in ("../evil.txt", "/etc/passwd", "sub/../../escape.txt"): | |
| 228 | + with self.subTest(bad_path=bad_path): | |
| 229 | + tampered = {"root": snapshot["root"], "files": dict(snapshot["files"])} | |
| 230 | + tampered["files"][bad_path] = {"sha256": "0" * 64, "size": 0, "kind": "text"} | |
| 231 | + with self.assertRaises(cm.ChangeManifestError): | |
| 232 | + cm.build_change_manifest(tampered, isolated_dir) | |
| 233 | + | |
| 234 | + | |
| 235 | +class UnifiedDiffTests(ChangeManifestTestCase): | |
| 236 | + def test_modified_text_file_produces_readable_unified_diff(self): | |
| 237 | + self._write_text(self.source_dir, "a.txt", TEXT_A) | |
| 238 | + snapshot = cm.snapshot_workspace(self.source_dir) | |
| 239 | + isolated_dir = self._copy_source_to_isolated() | |
| 240 | + self._write_text(isolated_dir, "a.txt", TEXT_A_MODIFIED) | |
| 241 | + | |
| 242 | + manifest = cm.build_change_manifest(snapshot, isolated_dir) | |
| 243 | + diff_text = cm.unified_diff(manifest) | |
| 244 | + lines = diff_text.splitlines() | |
| 245 | + | |
| 246 | + self.assertTrue(any(l.startswith("---") and "a.txt" in l for l in lines)) | |
| 247 | + self.assertTrue(any(l.startswith("+++") and "a.txt" in l for l in lines)) | |
| 248 | + self.assertTrue(any(l.startswith("@@") for l in lines)) | |
| 249 | + self.assertTrue(any(l.startswith("-") and "hello" in l and not l.startswith("---") for l in lines)) | |
| 250 | + self.assertTrue(any(l.startswith("+") and "hello modified" in l and not l.startswith("+++") for l in lines)) | |
| 251 | + | |
| 252 | + def test_added_text_file_diffs_against_dev_null(self): | |
| 253 | + snapshot = cm.snapshot_workspace(self.source_dir) | |
| 254 | + isolated_dir = self._copy_source_to_isolated() | |
| 255 | + self._write_text(isolated_dir, "new.txt", "brand new content\n") | |
| 256 | + | |
| 257 | + manifest = cm.build_change_manifest(snapshot, isolated_dir) | |
| 258 | + diff_text = cm.unified_diff(manifest) | |
| 259 | + lines = diff_text.splitlines() | |
| 260 | + | |
| 261 | + self.assertTrue(any("/dev/null" in l for l in lines)) | |
| 262 | + self.assertTrue(any(l.startswith("+") and "brand new content" in l for l in lines)) | |
| 263 | + removed_content_lines = [l for l in lines if l.startswith("-") and not l.startswith("---")] | |
| 264 | + self.assertEqual(removed_content_lines, []) | |
| 265 | + | |
| 266 | + def test_deleted_text_file_diffs_against_dev_null(self): | |
| 267 | + self._write_text(self.source_dir, "gone.txt", "will be deleted\n") | |
| 268 | + snapshot = cm.snapshot_workspace(self.source_dir) | |
| 269 | + isolated_dir = self._copy_source_to_isolated() | |
| 270 | + os.remove(os.path.join(isolated_dir, "gone.txt")) | |
| 271 | + | |
| 272 | + manifest = cm.build_change_manifest(snapshot, isolated_dir) | |
| 273 | + diff_text = cm.unified_diff(manifest) | |
| 274 | + lines = diff_text.splitlines() | |
| 275 | + | |
| 276 | + self.assertTrue(any("/dev/null" in l for l in lines)) | |
| 277 | + self.assertTrue(any(l.startswith("-") and "will be deleted" in l for l in lines)) | |
| 278 | + added_content_lines = [l for l in lines if l.startswith("+") and not l.startswith("+++")] | |
| 279 | + self.assertEqual(added_content_lines, []) | |
| 280 | + | |
| 281 | + def test_binary_file_diff_is_marker_not_content_diff(self): | |
| 282 | + snapshot = cm.snapshot_workspace(self.source_dir) | |
| 283 | + isolated_dir = self._copy_source_to_isolated() | |
| 284 | + self._write_bytes(isolated_dir, "logo.bin", BINARY_BYTES) | |
| 285 | + | |
| 286 | + manifest = cm.build_change_manifest(snapshot, isolated_dir) | |
| 287 | + diff_text = cm.unified_diff(manifest) | |
| 288 | + | |
| 289 | + self.assertIn("Binary files", diff_text) | |
| 290 | + self.assertIn("logo.bin", diff_text) | |
| 291 | + self.assertNotIn("@@", diff_text) | |
| 292 | + | |
| 293 | + def test_text_and_binary_changes_together_only_text_gets_hunks(self): | |
| 294 | + self._write_text(self.source_dir, "a.txt", TEXT_A) | |
| 295 | + self._write_text(self.source_dir, "gone.txt", "will be deleted\n") | |
| 296 | + snapshot = cm.snapshot_workspace(self.source_dir) | |
| 297 | + isolated_dir = self._copy_source_to_isolated() | |
| 298 | + | |
| 299 | + self._write_text(isolated_dir, "a.txt", TEXT_A_MODIFIED) | |
| 300 | + os.remove(os.path.join(isolated_dir, "gone.txt")) | |
| 301 | + self._write_text(isolated_dir, "new.txt", "added text\n") | |
| 302 | + self._write_bytes(isolated_dir, "logo.bin", BINARY_BYTES) | |
| 303 | + | |
| 304 | + manifest = cm.build_change_manifest(snapshot, isolated_dir) | |
| 305 | + diff_text = cm.unified_diff(manifest) | |
| 306 | + | |
| 307 | + text_change_count = sum(1 for entry in manifest["changed_files"] if entry["kind"] == "text") | |
| 308 | + self.assertEqual(diff_text.count("@@"), text_change_count) | |
| 309 | + self.assertIn("Binary files", diff_text) | |
| 310 | + | |
| 311 | + | |
| 312 | +if __name__ == "__main__": | |
| 313 | + unittest.main() | ... | ... |
supervisor/isolation/test_isolated_workspace.py
0 → 100644
| 1 | +"""U-036: real unittest coverage for isolated_workspace.py -- adapted from | |
| 2 | +graphloop's test_isolated_coding_workspace.py (2026-08-02). Module logic is | |
| 3 | +unchanged from graphloop (see isolated_workspace.py's own docstring); only | |
| 4 | +the import path changed to match this package's location. | |
| 5 | + | |
| 6 | +Run: python3 -m unittest discover -s supervisor/isolation -p "test_*.py" -v | |
| 7 | +""" | |
| 8 | +import os | |
| 9 | +import shutil | |
| 10 | +import tempfile | |
| 11 | +import unittest | |
| 12 | + | |
| 13 | +from supervisor.isolation import isolated_workspace as iso | |
| 14 | + | |
| 15 | + | |
| 16 | +class IsolateWorkspaceTests(unittest.TestCase): | |
| 17 | + def setUp(self): | |
| 18 | + self.source_dir = tempfile.mkdtemp(prefix="iso-src-") | |
| 19 | + self.addCleanup(shutil.rmtree, self.source_dir, ignore_errors=True) | |
| 20 | + | |
| 21 | + self.source_file = os.path.join(self.source_dir, "hello.txt") | |
| 22 | + with open(self.source_file, "w", encoding="utf-8") as handle: | |
| 23 | + handle.write("hello world") | |
| 24 | + | |
| 25 | + self._copies_to_clean = [] | |
| 26 | + self.addCleanup(self._cleanup_copies) | |
| 27 | + | |
| 28 | + def _cleanup_copies(self): | |
| 29 | + for path in self._copies_to_clean: | |
| 30 | + shutil.rmtree(path, ignore_errors=True) | |
| 31 | + | |
| 32 | + def test_returns_separate_path_leaves_source_unchanged_and_copies_file(self): | |
| 33 | + copied_dir = iso.isolate_workspace(self.source_dir) | |
| 34 | + self._copies_to_clean.append(copied_dir) | |
| 35 | + | |
| 36 | + self.assertNotEqual(os.path.abspath(copied_dir), os.path.abspath(self.source_dir)) | |
| 37 | + | |
| 38 | + self.assertTrue(os.path.isdir(self.source_dir)) | |
| 39 | + with open(self.source_file, "r", encoding="utf-8") as handle: | |
| 40 | + self.assertEqual(handle.read(), "hello world") | |
| 41 | + | |
| 42 | + copied_file = os.path.join(copied_dir, "hello.txt") | |
| 43 | + self.assertTrue(os.path.isfile(copied_file)) | |
| 44 | + with open(copied_file, "r", encoding="utf-8") as handle: | |
| 45 | + self.assertEqual(handle.read(), "hello world") | |
| 46 | + | |
| 47 | + def test_parent_dir_overrides_os_default_temp_location(self): | |
| 48 | + custom_parent = tempfile.mkdtemp(prefix="iso-parent-") | |
| 49 | + self.addCleanup(shutil.rmtree, custom_parent, ignore_errors=True) | |
| 50 | + | |
| 51 | + copied_dir = iso.isolate_workspace(self.source_dir, parent_dir=custom_parent) | |
| 52 | + self._copies_to_clean.append(copied_dir) | |
| 53 | + | |
| 54 | + self.assertEqual(os.path.dirname(copied_dir), custom_parent) | |
| 55 | + | |
| 56 | + def test_parent_dir_created_if_missing(self): | |
| 57 | + base = tempfile.mkdtemp(prefix="iso-parent-") | |
| 58 | + self.addCleanup(shutil.rmtree, base, ignore_errors=True) | |
| 59 | + custom_parent = os.path.join(base, "nested", "dir") | |
| 60 | + | |
| 61 | + copied_dir = iso.isolate_workspace(self.source_dir, parent_dir=custom_parent) | |
| 62 | + self._copies_to_clean.append(copied_dir) | |
| 63 | + | |
| 64 | + self.assertTrue(os.path.isdir(custom_parent)) | |
| 65 | + self.assertEqual(os.path.dirname(copied_dir), custom_parent) | |
| 66 | + | |
| 67 | + def test_rejects_nonexistent_source_dir(self): | |
| 68 | + with self.assertRaises(iso.IsolatedWorkspaceError): | |
| 69 | + iso.isolate_workspace(os.path.join(self.source_dir, "does-not-exist")) | |
| 70 | + | |
| 71 | + def test_rejects_empty_source_dir_argument(self): | |
| 72 | + with self.assertRaises(iso.IsolatedWorkspaceError): | |
| 73 | + iso.isolate_workspace("") | |
| 74 | + | |
| 75 | + | |
| 76 | +if __name__ == "__main__": | |
| 77 | + unittest.main() | ... | ... |
supervisor/isolation/test_selective_apply.py
0 → 100644
| 1 | +"""U-036: real unittest coverage for selective_apply.py -- adapted from | |
| 2 | +graphloop's test_selective_apply.py (2026-08-02). Module logic is unchanged | |
| 3 | +from graphloop except the internal `import change_manifest as cm` -> | |
| 4 | +relative package import (see selective_apply.py's own docstring); the | |
| 5 | +result key is `manifest_id` (not `digest`) in this port. | |
| 6 | + | |
| 7 | +Run: python3 -m unittest discover -s supervisor/isolation -p "test_*.py" -v | |
| 8 | +""" | |
| 9 | +import json | |
| 10 | +import os | |
| 11 | +import shutil | |
| 12 | +import tempfile | |
| 13 | +import unittest | |
| 14 | + | |
| 15 | +from supervisor.isolation import change_manifest as cm | |
| 16 | +from supervisor.isolation import selective_apply as sa | |
| 17 | + | |
| 18 | + | |
| 19 | +class SelectiveApplyTestCase(unittest.TestCase): | |
| 20 | + @staticmethod | |
| 21 | + def _write(root, rel_path, data): | |
| 22 | + full = os.path.join(root, *rel_path.split("/")) | |
| 23 | + os.makedirs(os.path.dirname(full), exist_ok=True) | |
| 24 | + with open(full, "wb") as handle: | |
| 25 | + handle.write(data) | |
| 26 | + return full | |
| 27 | + | |
| 28 | + def _build_scenario(self): | |
| 29 | + source_dir = tempfile.mkdtemp(prefix="sa-src-") | |
| 30 | + isolated_dir = tempfile.mkdtemp(prefix="sa-iso-") | |
| 31 | + self.addCleanup(shutil.rmtree, source_dir, ignore_errors=True) | |
| 32 | + self.addCleanup(shutil.rmtree, isolated_dir, ignore_errors=True) | |
| 33 | + | |
| 34 | + self._write(source_dir, "keep.txt", b"keep\n") | |
| 35 | + self._write(isolated_dir, "keep.txt", b"keep\n") | |
| 36 | + | |
| 37 | + self._write(source_dir, "mod.txt", b"original\n") | |
| 38 | + self._write(isolated_dir, "mod.txt", b"changed\n") | |
| 39 | + | |
| 40 | + self._write(source_dir, "old.txt", b"old content\n") | |
| 41 | + | |
| 42 | + self._write(isolated_dir, "new.txt", b"new content\n") | |
| 43 | + | |
| 44 | + source_snapshot = cm.snapshot_workspace(source_dir) | |
| 45 | + manifest = cm.build_change_manifest(source_snapshot, isolated_dir) | |
| 46 | + return source_dir, isolated_dir, manifest, source_snapshot | |
| 47 | + | |
| 48 | + def test_approved_paths_none_raises(self): | |
| 49 | + source_dir, isolated_dir, manifest, before = self._build_scenario() | |
| 50 | + with self.assertRaises(sa.SelectiveApplyError): | |
| 51 | + sa.apply_manifest(manifest, source_dir, isolated_dir, None) | |
| 52 | + after = cm.snapshot_workspace(source_dir) | |
| 53 | + self.assertEqual(before, after) | |
| 54 | + | |
| 55 | + def test_rejects_path_not_in_manifest_and_leaves_source_untouched(self): | |
| 56 | + source_dir, isolated_dir, manifest, before = self._build_scenario() | |
| 57 | + with self.assertRaises(sa.SelectiveApplyError): | |
| 58 | + sa.apply_manifest(manifest, source_dir, isolated_dir, ["mod.txt", "not_in_manifest.txt"]) | |
| 59 | + after = cm.snapshot_workspace(source_dir) | |
| 60 | + self.assertEqual(before, after) | |
| 61 | + | |
| 62 | + def test_rejects_absolute_and_traversal_paths(self): | |
| 63 | + source_dir, isolated_dir, manifest, before = self._build_scenario() | |
| 64 | + for bad_path in ["/etc/passwd", "../escape.txt", "sub/../../escape.txt"]: | |
| 65 | + with self.assertRaises(sa.SelectiveApplyError): | |
| 66 | + sa.apply_manifest(manifest, source_dir, isolated_dir, [bad_path]) | |
| 67 | + after = cm.snapshot_workspace(source_dir) | |
| 68 | + self.assertEqual(before, after) | |
| 69 | + | |
| 70 | + def test_applies_only_approved_subset_and_preserves_the_rest(self): | |
| 71 | + source_dir, isolated_dir, manifest, _ = self._build_scenario() | |
| 72 | + result = sa.apply_manifest(manifest, source_dir, isolated_dir, ["mod.txt"]) | |
| 73 | + | |
| 74 | + self.assertEqual(result["applied"], ["mod.txt"]) | |
| 75 | + self.assertEqual(sorted(result["skipped"]), ["new.txt", "old.txt"]) | |
| 76 | + self.assertEqual(result["already_applied"], []) | |
| 77 | + | |
| 78 | + with open(os.path.join(source_dir, "mod.txt"), "rb") as handle: | |
| 79 | + self.assertEqual(handle.read(), b"changed\n") | |
| 80 | + self.assertFalse(os.path.exists(os.path.join(source_dir, "new.txt"))) | |
| 81 | + with open(os.path.join(source_dir, "old.txt"), "rb") as handle: | |
| 82 | + self.assertEqual(handle.read(), b"old content\n") | |
| 83 | + with open(os.path.join(source_dir, "keep.txt"), "rb") as handle: | |
| 84 | + self.assertEqual(handle.read(), b"keep\n") | |
| 85 | + | |
| 86 | + def test_apply_add_modify_delete_full_manifest(self): | |
| 87 | + source_dir, isolated_dir, manifest, _ = self._build_scenario() | |
| 88 | + result = sa.apply_manifest(manifest, source_dir, isolated_dir, ["mod.txt", "new.txt", "old.txt"]) | |
| 89 | + | |
| 90 | + self.assertEqual(sorted(result["applied"]), ["mod.txt", "new.txt", "old.txt"]) | |
| 91 | + self.assertEqual(result["skipped"], []) | |
| 92 | + self.assertEqual(result["already_applied"], []) | |
| 93 | + | |
| 94 | + with open(os.path.join(source_dir, "mod.txt"), "rb") as handle: | |
| 95 | + self.assertEqual(handle.read(), b"changed\n") | |
| 96 | + with open(os.path.join(source_dir, "new.txt"), "rb") as handle: | |
| 97 | + self.assertEqual(handle.read(), b"new content\n") | |
| 98 | + self.assertFalse(os.path.exists(os.path.join(source_dir, "old.txt"))) | |
| 99 | + with open(os.path.join(source_dir, "keep.txt"), "rb") as handle: | |
| 100 | + self.assertEqual(handle.read(), b"keep\n") | |
| 101 | + | |
| 102 | + def test_source_drift_rejected_before_any_write(self): | |
| 103 | + source_dir, isolated_dir, manifest, expected_snapshot = self._build_scenario() | |
| 104 | + | |
| 105 | + self._write(source_dir, "keep.txt", b"drifted!\n") | |
| 106 | + | |
| 107 | + with self.assertRaises(sa.SelectiveApplyError): | |
| 108 | + sa.apply_manifest( | |
| 109 | + manifest, source_dir, isolated_dir, ["mod.txt", "new.txt", "old.txt"], | |
| 110 | + expected_source_snapshot=expected_snapshot, | |
| 111 | + ) | |
| 112 | + | |
| 113 | + with open(os.path.join(source_dir, "mod.txt"), "rb") as handle: | |
| 114 | + self.assertEqual(handle.read(), b"original\n") | |
| 115 | + self.assertFalse(os.path.exists(os.path.join(source_dir, "new.txt"))) | |
| 116 | + with open(os.path.join(source_dir, "old.txt"), "rb") as handle: | |
| 117 | + self.assertEqual(handle.read(), b"old content\n") | |
| 118 | + with open(os.path.join(source_dir, "keep.txt"), "rb") as handle: | |
| 119 | + self.assertEqual(handle.read(), b"drifted!\n") | |
| 120 | + | |
| 121 | + def test_idempotent_second_apply_reports_already_applied(self): | |
| 122 | + source_dir, isolated_dir, manifest, _ = self._build_scenario() | |
| 123 | + approved = ["mod.txt", "new.txt", "old.txt"] | |
| 124 | + | |
| 125 | + first = sa.apply_manifest(manifest, source_dir, isolated_dir, approved) | |
| 126 | + self.assertEqual(sorted(first["applied"]), sorted(approved)) | |
| 127 | + | |
| 128 | + snapshot_after_first = cm.snapshot_workspace(source_dir) | |
| 129 | + | |
| 130 | + second = sa.apply_manifest(manifest, source_dir, isolated_dir, approved) | |
| 131 | + self.assertEqual(second["applied"], []) | |
| 132 | + self.assertEqual(sorted(second["already_applied"]), sorted(approved)) | |
| 133 | + | |
| 134 | + snapshot_after_second = cm.snapshot_workspace(source_dir) | |
| 135 | + self.assertEqual(snapshot_after_first, snapshot_after_second) | |
| 136 | + | |
| 137 | + def test_symlink_destination_rejected(self): | |
| 138 | + source_dir = tempfile.mkdtemp(prefix="sa-src-") | |
| 139 | + isolated_dir = tempfile.mkdtemp(prefix="sa-iso-") | |
| 140 | + self.addCleanup(shutil.rmtree, source_dir, ignore_errors=True) | |
| 141 | + self.addCleanup(shutil.rmtree, isolated_dir, ignore_errors=True) | |
| 142 | + | |
| 143 | + real_target = self._write(source_dir, "real.txt", b"real\n") | |
| 144 | + symlink_path = os.path.join(source_dir, "linked.txt") | |
| 145 | + os.symlink(real_target, symlink_path) | |
| 146 | + | |
| 147 | + self._write(isolated_dir, "linked.txt", b"attacker controlled\n") | |
| 148 | + | |
| 149 | + source_snapshot = cm.snapshot_workspace(source_dir) | |
| 150 | + manifest = cm.build_change_manifest(source_snapshot, isolated_dir) | |
| 151 | + self.assertIn("linked.txt", [entry["path"] for entry in manifest["changed_files"]]) | |
| 152 | + | |
| 153 | + with self.assertRaises(sa.SelectiveApplyError): | |
| 154 | + sa.apply_manifest(manifest, source_dir, isolated_dir, ["linked.txt"]) | |
| 155 | + | |
| 156 | + self.assertTrue(os.path.islink(symlink_path)) | |
| 157 | + self.assertEqual(os.readlink(symlink_path), real_target) | |
| 158 | + with open(real_target, "rb") as handle: | |
| 159 | + self.assertEqual(handle.read(), b"real\n") | |
| 160 | + | |
| 161 | + def test_result_is_json_serializable_and_carries_stable_identifier(self): | |
| 162 | + source_dir, isolated_dir, manifest, _ = self._build_scenario() | |
| 163 | + result = sa.apply_manifest(manifest, source_dir, isolated_dir, ["mod.txt"]) | |
| 164 | + | |
| 165 | + json.dumps(result) | |
| 166 | + | |
| 167 | + self.assertIn("manifest_id", result) | |
| 168 | + self.assertIsInstance(result["manifest_id"], str) | |
| 169 | + self.assertTrue(result["manifest_id"]) | |
| 170 | + | |
| 171 | + second = sa.apply_manifest(manifest, source_dir, isolated_dir, ["mod.txt"]) | |
| 172 | + self.assertEqual(result["manifest_id"], second["manifest_id"]) | |
| 173 | + | |
| 174 | + | |
| 175 | +if __name__ == "__main__": | |
| 176 | + unittest.main() | ... | ... |
supervisor/isolation/test_workspace_security.py
0 → 100644
| 1 | +"""U-036: real unittest coverage for workspace_security.py -- adapted from | |
| 2 | +graphloop's test_workspace_security.py (2026-08-02). Module logic is | |
| 3 | +unchanged from graphloop ("adapted verbatim", see workspace_security.py's | |
| 4 | +own docstring); only the import path changed. | |
| 5 | + | |
| 6 | +Run: python3 -m unittest discover -s supervisor/isolation -p "test_*.py" -v | |
| 7 | +""" | |
| 8 | + | |
| 9 | +import os | |
| 10 | +import shutil | |
| 11 | +import sys | |
| 12 | +import tempfile | |
| 13 | +import unittest | |
| 14 | + | |
| 15 | +from supervisor.isolation import workspace_security as ws | |
| 16 | + | |
| 17 | + | |
| 18 | +class ValidateArtifactsTests(unittest.TestCase): | |
| 19 | + def setUp(self): | |
| 20 | + self.workspace = tempfile.mkdtemp(prefix="wssec-workspace-") | |
| 21 | + self.addCleanup(shutil.rmtree, self.workspace, ignore_errors=True) | |
| 22 | + | |
| 23 | + self.outside_dir = tempfile.mkdtemp(prefix="wssec-outside-") | |
| 24 | + self.addCleanup(shutil.rmtree, self.outside_dir, ignore_errors=True) | |
| 25 | + | |
| 26 | + def _write(self, relative_path, content="content"): | |
| 27 | + full_path = os.path.join(self.workspace, relative_path) | |
| 28 | + os.makedirs(os.path.dirname(full_path), exist_ok=True) | |
| 29 | + with open(full_path, "w", encoding="utf-8") as handle: | |
| 30 | + handle.write(content) | |
| 31 | + return full_path | |
| 32 | + | |
| 33 | + def test_accepts_and_normalizes_plain_relative_artifacts(self): | |
| 34 | + self._write("output.txt") | |
| 35 | + self._write(os.path.join("nested", "dir", "file.txt")) | |
| 36 | + | |
| 37 | + result = ws.validate_artifacts(["output.txt", "nested/dir/file.txt"], self.workspace) | |
| 38 | + | |
| 39 | + self.assertEqual(result, ["output.txt", "nested/dir/file.txt"]) | |
| 40 | + | |
| 41 | + def test_normalizes_redundant_leading_dot_slash(self): | |
| 42 | + self._write("output.txt") | |
| 43 | + | |
| 44 | + result = ws.validate_artifacts(["./output.txt"], self.workspace) | |
| 45 | + | |
| 46 | + self.assertEqual(result, ["output.txt"]) | |
| 47 | + | |
| 48 | + def test_rejects_non_string_item(self): | |
| 49 | + self._write("output.txt") | |
| 50 | + | |
| 51 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 52 | + ws.validate_artifacts(["output.txt", 42], self.workspace) | |
| 53 | + | |
| 54 | + def test_rejects_non_list_artifacts_argument(self): | |
| 55 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 56 | + ws.validate_artifacts("output.txt", self.workspace) | |
| 57 | + | |
| 58 | + def test_rejects_absolute_path_artifact(self): | |
| 59 | + absolute = self._write("output.txt") | |
| 60 | + | |
| 61 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 62 | + ws.validate_artifacts([absolute], self.workspace) | |
| 63 | + | |
| 64 | + def test_rejects_dot_dot_traversal_even_when_target_exists(self): | |
| 65 | + self._write("output.txt") | |
| 66 | + os.makedirs(os.path.join(self.workspace, "sibling"), exist_ok=True) | |
| 67 | + | |
| 68 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 69 | + ws.validate_artifacts(["sibling/../output.txt"], self.workspace) | |
| 70 | + | |
| 71 | + def test_rejects_dot_dot_traversal_escaping_workspace(self): | |
| 72 | + secret = os.path.join(self.outside_dir, "secret.txt") | |
| 73 | + with open(secret, "w", encoding="utf-8") as handle: | |
| 74 | + handle.write("secret") | |
| 75 | + | |
| 76 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 77 | + ws.validate_artifacts( | |
| 78 | + [os.path.join("..", os.path.basename(self.outside_dir), "secret.txt")], | |
| 79 | + self.workspace, | |
| 80 | + ) | |
| 81 | + | |
| 82 | + def test_rejects_missing_artifact(self): | |
| 83 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 84 | + ws.validate_artifacts(["does_not_exist.txt"], self.workspace) | |
| 85 | + | |
| 86 | + def test_rejects_symlink_artifact(self): | |
| 87 | + real_file = self._write("real.txt") | |
| 88 | + symlink_path = os.path.join(self.workspace, "link.txt") | |
| 89 | + os.symlink(real_file, symlink_path) | |
| 90 | + self.addCleanup(lambda: os.path.exists(symlink_path) and os.remove(symlink_path)) | |
| 91 | + | |
| 92 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 93 | + ws.validate_artifacts(["link.txt"], self.workspace) | |
| 94 | + | |
| 95 | + def test_rejects_artifact_resolving_outside_workspace_via_symlinked_directory(self): | |
| 96 | + secret = os.path.join(self.outside_dir, "secret.txt") | |
| 97 | + with open(secret, "w", encoding="utf-8") as handle: | |
| 98 | + handle.write("secret") | |
| 99 | + | |
| 100 | + symlinked_dir = os.path.join(self.workspace, "escape") | |
| 101 | + os.symlink(self.outside_dir, symlinked_dir, target_is_directory=True) | |
| 102 | + self.addCleanup(lambda: os.path.exists(symlinked_dir) and os.remove(symlinked_dir)) | |
| 103 | + | |
| 104 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 105 | + ws.validate_artifacts(["escape/secret.txt"], self.workspace) | |
| 106 | + | |
| 107 | + def test_rejects_empty_string_artifact(self): | |
| 108 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 109 | + ws.validate_artifacts([""], self.workspace) | |
| 110 | + | |
| 111 | + | |
| 112 | +class ValidateSubprocessCommandTests(unittest.TestCase): | |
| 113 | + def setUp(self): | |
| 114 | + self.workspace = tempfile.mkdtemp(prefix="wssec-cmd-workspace-") | |
| 115 | + self.addCleanup(shutil.rmtree, self.workspace, ignore_errors=True) | |
| 116 | + | |
| 117 | + self.adapter_script = os.path.join(self.workspace, "existing_adapter.py") | |
| 118 | + with open(self.adapter_script, "w", encoding="utf-8") as handle: | |
| 119 | + handle.write("print('ok')\n") | |
| 120 | + | |
| 121 | + def test_accepts_python_executable_with_existing_script(self): | |
| 122 | + result = ws.validate_subprocess_command([sys.executable, self.adapter_script]) | |
| 123 | + | |
| 124 | + self.assertEqual(result, [sys.executable, self.adapter_script]) | |
| 125 | + | |
| 126 | + def test_rejects_empty_command(self): | |
| 127 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 128 | + ws.validate_subprocess_command([]) | |
| 129 | + | |
| 130 | + def test_rejects_non_list_command(self): | |
| 131 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 132 | + ws.validate_subprocess_command("{} {}".format(sys.executable, self.adapter_script)) | |
| 133 | + | |
| 134 | + def test_rejects_non_string_argument(self): | |
| 135 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 136 | + ws.validate_subprocess_command([sys.executable, self.adapter_script, 7]) | |
| 137 | + | |
| 138 | + def test_rejects_shell_metacharacters_in_arguments(self): | |
| 139 | + dangerous_args = [ | |
| 140 | + "foo; rm -rf /", "foo && rm -rf /", "foo | rm -rf /", | |
| 141 | + "foo > /etc/passwd", "foo `rm -rf /`", "foo $(rm -rf /)", | |
| 142 | + ] | |
| 143 | + for dangerous_arg in dangerous_args: | |
| 144 | + with self.subTest(dangerous_arg=dangerous_arg): | |
| 145 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 146 | + ws.validate_subprocess_command([sys.executable, self.adapter_script, dangerous_arg]) | |
| 147 | + | |
| 148 | + def test_rejects_bare_shell_invocation(self): | |
| 149 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 150 | + ws.validate_subprocess_command(["/bin/sh", "-c", "echo hi"]) | |
| 151 | + | |
| 152 | + def test_rejects_script_argument_with_disallowed_extension(self): | |
| 153 | + non_script = os.path.join(self.workspace, "not_a_script.txt") | |
| 154 | + with open(non_script, "w", encoding="utf-8") as handle: | |
| 155 | + handle.write("not python") | |
| 156 | + | |
| 157 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 158 | + ws.validate_subprocess_command([sys.executable, non_script]) | |
| 159 | + | |
| 160 | + def test_rejects_script_argument_that_does_not_exist(self): | |
| 161 | + missing_script = os.path.join(self.workspace, "missing_adapter.py") | |
| 162 | + | |
| 163 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 164 | + ws.validate_subprocess_command([sys.executable, missing_script]) | |
| 165 | + | |
| 166 | + def test_rejects_newline_in_argument(self): | |
| 167 | + with self.assertRaises(ws.WorkspaceSecurityError): | |
| 168 | + ws.validate_subprocess_command([sys.executable, self.adapter_script, "foo\nbar"]) | |
| 169 | + | |
| 170 | + | |
| 171 | +if __name__ == "__main__": | |
| 172 | + unittest.main() | ... | ... |
supervisor/test_deploy_supervisor.py
0 → 100644
| 1 | +"""U-036 tests: Deploy Supervisor process skeleton -- lifecycle only (PID | |
| 2 | +file, status file, start/stop), no actual deploy logic (that's U-037+). | |
| 3 | +GRAPHLEMON_HOME is always overridden to a throwaway tempdir so these tests | |
| 4 | +never touch the real user's ~/.graphlemon. | |
| 5 | + | |
| 6 | +Run: python3 -m unittest discover -s supervisor -p "test_*.py" -v | |
| 7 | +""" | |
| 8 | + | |
| 9 | +import os | |
| 10 | +import shutil | |
| 11 | +import signal | |
| 12 | +import subprocess | |
| 13 | +import sys | |
| 14 | +import tempfile | |
| 15 | +import time | |
| 16 | +import unittest | |
| 17 | + | |
| 18 | +from supervisor import deploy_supervisor as ds | |
| 19 | +from src import paths | |
| 20 | + | |
| 21 | + | |
| 22 | +class DeploySupervisorTestBase(unittest.TestCase): | |
| 23 | + def setUp(self): | |
| 24 | + self.tmp = tempfile.mkdtemp(prefix="deploy-supervisor-test-") | |
| 25 | + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) | |
| 26 | + self.old_home = os.environ.get("GRAPHLEMON_HOME") | |
| 27 | + os.environ["GRAPHLEMON_HOME"] = self.tmp | |
| 28 | + | |
| 29 | + def tearDown(self): | |
| 30 | + if self.old_home is None: | |
| 31 | + os.environ.pop("GRAPHLEMON_HOME", None) | |
| 32 | + else: | |
| 33 | + os.environ["GRAPHLEMON_HOME"] = self.old_home | |
| 34 | + | |
| 35 | + | |
| 36 | +class RuntimePathsTest(DeploySupervisorTestBase): | |
| 37 | + def test_runtime_dir_created_under_platform_home(self): | |
| 38 | + d = ds.runtime_dir() | |
| 39 | + self.assertTrue(os.path.isdir(d)) | |
| 40 | + self.assertTrue(d.startswith(paths.platform_home())) | |
| 41 | + | |
| 42 | + def test_pid_and_status_file_paths_live_under_runtime_dir(self): | |
| 43 | + self.assertEqual(os.path.dirname(ds.pid_file_path()), ds.runtime_dir()) | |
| 44 | + self.assertEqual(os.path.dirname(ds.status_file_path()), ds.runtime_dir()) | |
| 45 | + | |
| 46 | + | |
| 47 | +class IsAliveTest(unittest.TestCase): | |
| 48 | + def test_current_process_is_alive(self): | |
| 49 | + self.assertTrue(ds.is_alive(os.getpid())) | |
| 50 | + | |
| 51 | + def test_zero_or_negative_pid_is_not_alive(self): | |
| 52 | + self.assertFalse(ds.is_alive(0)) | |
| 53 | + self.assertFalse(ds.is_alive(-1)) | |
| 54 | + | |
| 55 | + def test_non_int_is_not_alive(self): | |
| 56 | + self.assertFalse(ds.is_alive("not-a-pid")) | |
| 57 | + | |
| 58 | + def test_a_pid_that_does_not_exist_is_not_alive(self): | |
| 59 | + # PID_MAX on Linux/macOS is well below this; astronomically unlikely | |
| 60 | + # to collide with a real running process. | |
| 61 | + self.assertFalse(ds.is_alive(2**30)) | |
| 62 | + | |
| 63 | + | |
| 64 | +class StatusFileTest(DeploySupervisorTestBase): | |
| 65 | + def test_write_then_read_status_round_trips(self): | |
| 66 | + ds.write_status("running", detail="test detail") | |
| 67 | + status = ds.read_status() | |
| 68 | + self.assertEqual(status["status"], "running") | |
| 69 | + self.assertEqual(status["detail"], "test detail") | |
| 70 | + self.assertEqual(status["pid"], os.getpid()) | |
| 71 | + | |
| 72 | + def test_read_status_returns_none_when_no_file_yet(self): | |
| 73 | + self.assertIsNone(ds.read_status()) | |
| 74 | + | |
| 75 | + def test_write_status_is_atomic_no_tmp_file_left_behind(self): | |
| 76 | + ds.write_status("running") | |
| 77 | + entries = os.listdir(ds.runtime_dir()) | |
| 78 | + self.assertEqual(entries, ["supervisor.status.json"]) | |
| 79 | + | |
| 80 | + | |
| 81 | +class IsRunningTest(DeploySupervisorTestBase): | |
| 82 | + def test_false_when_no_pid_file(self): | |
| 83 | + self.assertFalse(ds.is_running()) | |
| 84 | + | |
| 85 | + def test_true_when_pid_file_points_to_a_live_process(self): | |
| 86 | + with open(ds.pid_file_path(), "w", encoding="utf-8") as handle: | |
| 87 | + handle.write(str(os.getpid())) | |
| 88 | + self.assertTrue(ds.is_running()) | |
| 89 | + | |
| 90 | + def test_false_when_pid_file_points_to_a_dead_process(self): | |
| 91 | + with open(ds.pid_file_path(), "w", encoding="utf-8") as handle: | |
| 92 | + handle.write(str(2**30)) | |
| 93 | + self.assertFalse(ds.is_running()) | |
| 94 | + | |
| 95 | + def test_false_when_pid_file_is_corrupt(self): | |
| 96 | + with open(ds.pid_file_path(), "w", encoding="utf-8") as handle: | |
| 97 | + handle.write("not-a-number") | |
| 98 | + self.assertFalse(ds.is_running()) | |
| 99 | + | |
| 100 | + | |
| 101 | +class RunForegroundTest(DeploySupervisorTestBase): | |
| 102 | + def test_writes_pid_file_and_running_status_during_the_loop(self): | |
| 103 | + seen_running = [] | |
| 104 | + | |
| 105 | + def poll_fn(): | |
| 106 | + seen_running.append(ds.is_running()) | |
| 107 | + seen_running.append(ds.read_status()["status"]) | |
| 108 | + | |
| 109 | + ds.run_foreground(poll_fn=poll_fn, poll_interval=0, iterations=1) | |
| 110 | + | |
| 111 | + self.assertEqual(seen_running, [True, "running"]) | |
| 112 | + | |
| 113 | + def test_calls_poll_fn_exactly_iterations_times(self): | |
| 114 | + calls = [] | |
| 115 | + ds.run_foreground(poll_fn=lambda: calls.append(1), poll_interval=0, iterations=3) | |
| 116 | + self.assertEqual(len(calls), 3) | |
| 117 | + | |
| 118 | + def test_writes_stopped_status_and_removes_pid_file_on_normal_completion(self): | |
| 119 | + ds.run_foreground(poll_fn=None, poll_interval=0, iterations=1) | |
| 120 | + self.assertEqual(ds.read_status()["status"], "stopped") | |
| 121 | + self.assertFalse(os.path.exists(ds.pid_file_path())) | |
| 122 | + | |
| 123 | + def test_sigterm_breaks_the_loop_and_still_writes_stopped_status(self): | |
| 124 | + calls = [] | |
| 125 | + | |
| 126 | + def poll_fn(): | |
| 127 | + calls.append(1) | |
| 128 | + if len(calls) == 2: | |
| 129 | + os.kill(os.getpid(), signal.SIGTERM) | |
| 130 | + | |
| 131 | + # iterations=None (would run forever) -- SIGTERM is the only exit. | |
| 132 | + ds.run_foreground(poll_fn=poll_fn, poll_interval=0, iterations=None) | |
| 133 | + | |
| 134 | + self.assertEqual(len(calls), 2) | |
| 135 | + self.assertEqual(ds.read_status()["status"], "stopped") | |
| 136 | + self.assertFalse(os.path.exists(ds.pid_file_path())) | |
| 137 | + | |
| 138 | + def test_poll_fn_exception_propagates_but_cleanup_still_runs(self): | |
| 139 | + def poll_fn(): | |
| 140 | + raise RuntimeError("boom") | |
| 141 | + | |
| 142 | + with self.assertRaises(RuntimeError): | |
| 143 | + ds.run_foreground(poll_fn=poll_fn, poll_interval=0, iterations=1) | |
| 144 | + | |
| 145 | + self.assertEqual(ds.read_status()["status"], "stopped") | |
| 146 | + self.assertFalse(os.path.exists(ds.pid_file_path())) | |
| 147 | + | |
| 148 | + | |
| 149 | +class RealSubprocessLifecycleTest(DeploySupervisorTestBase): | |
| 150 | + """Not mocked -- actually spawns `python3 -m supervisor.deploy_supervisor` | |
| 151 | + as a real independent OS process and stops it for real, per this Unit's | |
| 152 | + Done criterion ("骨架能独立启停").""" | |
| 153 | + | |
| 154 | + def test_start_detached_then_stop(self): | |
| 155 | + env = dict(os.environ) | |
| 156 | + # start_detached itself calls is_running()/Popen using the current | |
| 157 | + # process's env var (already patched by setUp); the child process | |
| 158 | + # inherits it via Popen's default env=None -> os.environ passthrough. | |
| 159 | + pid = ds.start_detached(poll_interval=0.1) | |
| 160 | + try: | |
| 161 | + deadline = time.time() + 5 | |
| 162 | + while time.time() < deadline and not ds.is_running(): | |
| 163 | + time.sleep(0.05) | |
| 164 | + self.assertTrue(ds.is_running()) | |
| 165 | + self.assertTrue(ds.is_alive(pid)) | |
| 166 | + | |
| 167 | + stopped = ds.stop(timeout=5) | |
| 168 | + self.assertTrue(stopped) | |
| 169 | + self.assertFalse(ds.is_running()) | |
| 170 | + finally: | |
| 171 | + if ds.is_alive(pid): | |
| 172 | + os.kill(pid, signal.SIGKILL) | |
| 173 | + | |
| 174 | + def test_start_detached_twice_raises_while_already_running(self): | |
| 175 | + pid = ds.start_detached(poll_interval=0.1) | |
| 176 | + try: | |
| 177 | + deadline = time.time() + 5 | |
| 178 | + while time.time() < deadline and not ds.is_running(): | |
| 179 | + time.sleep(0.05) | |
| 180 | + self.assertTrue(ds.is_running()) | |
| 181 | + with self.assertRaises(RuntimeError): | |
| 182 | + ds.start_detached(poll_interval=0.1) | |
| 183 | + finally: | |
| 184 | + ds.stop(timeout=5) | |
| 185 | + if ds.is_alive(pid): | |
| 186 | + os.kill(pid, signal.SIGKILL) | |
| 187 | + | |
| 188 | + def test_stop_returns_false_when_nothing_is_running(self): | |
| 189 | + self.assertFalse(ds.stop(timeout=1)) | |
| 190 | + | |
| 191 | + | |
| 192 | +if __name__ == "__main__": | |
| 193 | + unittest.main() | ... | ... |