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,6 +354,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | ||
| 354 | - 依赖:无(代码已存在,是补测试和补骨架,不是从零开发) | 354 | - 依赖:无(代码已存在,是补测试和补骨架,不是从零开发) |
| 355 | - Done:四模块各自有对应 `test_*.py`(可直接照搬/改造 graphloop 的 `test_change_manifest.py`/`test_selective_apply.py`/`test_workspace_security.py` 等,逻辑未改动,测试也基本不用改);Deploy Supervisor 骨架能独立启停,不受 Live/Candidate 进程崩溃影响 | 355 | - Done:四模块各自有对应 `test_*.py`(可直接照搬/改造 graphloop 的 `test_change_manifest.py`/`test_selective_apply.py`/`test_workspace_security.py` 等,逻辑未改动,测试也基本不用改);Deploy Supervisor 骨架能独立启停,不受 Live/Candidate 进程崩溃影响 |
| 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/`),不存在真正的"归入目标项目"选项;保留这条记录是为了让后来者理解为什么这里曾经有过一次误判 | 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 | - **U-037 [Risk Gate]** `c_deploy_supervisor_stage`:候选实例拉起(全新目录 checkout,不 `git pull` 原地改)+ 一次性 DB 副本机制(候选实例验证期间绝不接触生产库) | 361 | - **U-037 [Risk Gate]** `c_deploy_supervisor_stage`:候选实例拉起(全新目录 checkout,不 `git pull` 原地改)+ 一次性 DB 副本机制(候选实例验证期间绝不接触生产库) |
| 359 | - 依赖:U-034, U-036 | 362 | - 依赖:U-034, U-036 |
docs/sdd/unit_matrix.json
| @@ -53,7 +53,7 @@ | @@ -53,7 +53,7 @@ | ||
| 53 | 53 | ||
| 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"}, | 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 | {"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 提前做"}, | 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 | {"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 副本的候选进程"}, | 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 | {"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 未受影响"}, | 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 | {"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 保留至监控窗口结束"}, | 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() |