diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index ea5feadce..2aa65c220 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,19 @@ +## 2026-07-03 — 10:48 Windows99 VMX source repair check-mode package + +**完成內容**: +- 新增 `scripts/reboot-recovery/build-windows99-vmx-source-repair-package.py`,把 production reboot SLO scorecard 內的 `windows99_missing_vmx_aliases` / `windows99_powered_off_aliases` 轉成 no-secret check-mode package。 +- Live artifact 已產生:`/tmp/awoooi-p0-006-windows99-vmx-package-20260703/package.json`。目前 `collector_status=collected_windows99_vmware_verify_stdout`、`missing_vmx_aliases=["111"]`、`powered_off_aliases=["111","112","120","121","188"]`,111 expected VMX path 為 `D:\Documents\Virtual Machines\192.168.0.111_Ubuntu_64-bit\192.168.0.111_Ubuntu_64-bit.vmx`。 +- Package 明確 `apply_allowed_by_this_package=false`,只輸出 `Test-Path` 類 read-only probe command、controlled restore hint 與 post-verifier;不授權 VM power change、`vmrun start/stop`、Windows service restart、registry apply、scheduled task modify、host reboot 或 secret/password 讀取。 + +**已跑驗證**: +- `python3.11 -m pytest scripts/reboot-recovery/tests/test_windows99_vmx_source_repair_package.py scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py -q -p no:cacheprovider`:`27 passed`。 +- `python3.11 -m py_compile scripts/reboot-recovery/build-windows99-vmx-source-repair-package.py scripts/reboot-recovery/tests/test_windows99_vmx_source_repair_package.py`:通過。 +- `python3.11 ops/runner/guard-gitea-runner-pressure.py --root .`:`GITEA_RUNNER_PRESSURE_GUARD_OK`。 +- `git diff --check`:通過。 + +**仍維持**: +- 未讀 secret / token / `.env` / raw sessions / SQLite / auth;未使用 GitHub / gh;未 workflow_dispatch;未啟動或關閉 VM;未重啟 host / service;未 Docker / Nginx / K3s / DB / firewall restart;未 DROP / TRUNCATE / restore / prune / delete / force push。 + ## 2026-07-03 — 10:44 CD bounded wrapper production readback 成功 **完成內容**: diff --git a/scripts/reboot-recovery/build-windows99-vmx-source-repair-package.py b/scripts/reboot-recovery/build-windows99-vmx-source-repair-package.py new file mode 100644 index 000000000..8125369c3 --- /dev/null +++ b/scripts/reboot-recovery/build-windows99-vmx-source-repair-package.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Build a no-secret Windows99 VMX source repair check-mode package. + +This package turns the reboot SLO scorecard's Windows99 VMX blockers into a +bounded source-repair plan. It never starts VMs, edits Windows, restarts +services, reads secrets, or applies registry/task changes. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.request +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +DEFAULT_SCORECARD_URL = ( + "https://awoooi.wooo.work/api/v1/agents/reboot-auto-recovery-slo-scorecard" +) +DEFAULT_REQUIRED_ALIASES = ["111", "188", "120", "121", "112"] +EXPECTED_VMX_CANDIDATES = { + "111": [ + r"D:\Documents\Virtual Machines\192.168.0.111_Ubuntu_64-bit\192.168.0.111_Ubuntu_64-bit.vmx", + ], + "188": [ + r"D:\Documents\Virtual Machines\Ollama_Ubuntu_64-bit\Ollama_Ubuntu_64-bit.vmx", + ], + "120": [ + r"D:\Documents\Virtual Machines\192.168.0.120_Ubuntu_64-bit\192.168.0.120_Ubuntu_64-bit.vmx", + ], + "121": [ + r"D:\Documents\Virtual Machines\192.168.0.121_Ubuntu_64-bit\192.168.0.121_Ubuntu_64-bit.vmx", + ], + "112": [ + r"D:\Downloads\kali-linux-2025.4-vmware-amd64\kali-linux-2025.4-vmware-amd64.vmwarevm\kali-linux-2025.4-vmware-amd64.vmx", + ], +} +MISSING_VMX_NEXT_STEP = ( + "restore_windows99_missing_vmx_source_for_aliases_then_rerun_no_secret_" + "collector_and_scorecard_no_vm_power_change" +) +POWERED_OFF_NEXT_STEP = ( + "build_windows99_vmware_autostart_check_mode_package_for_powered_off_aliases_" + "then_rerun_no_secret_collector_no_vm_power_change" +) +FORBIDDEN_ACTIONS = [ + "read_windows_password_or_secret", + "vm_power_change", + "vmrun_start_or_stop", + "host_reboot", + "windows_service_restart", + "windows_registry_apply", + "scheduled_task_register_or_modify", + "docker_nginx_k3s_db_firewall_restart", +] + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _strings(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + if isinstance(value, str): + return [part.strip() for part in value.split(",") if part.strip()] + return [] + + +def _unique(values: list[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + if value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def _load_json_file(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + return payload if isinstance(payload, dict) else {} + + +def _fetch_json_url(url: str, timeout_seconds: float) -> dict[str, Any]: + request = urllib.request.Request( + url, + headers={"User-Agent": "awoooi-windows99-vmx-package/1.0"}, + ) + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + payload = json.load(response) + return payload if isinstance(payload, dict) else {} + + +def _taipei_now() -> str: + return datetime.now(ZoneInfo("Asia/Taipei")).replace(microsecond=0).isoformat() + + +def _powershell_test_path(path: str) -> str: + escaped = path.replace("'", "''") + return f"Test-Path -LiteralPath '{escaped}'" + + +def build_package(scorecard: dict[str, Any], *, generated_at: str | None = None) -> dict[str, Any]: + windows99 = _dict(scorecard.get("windows99_vmware_autostart")) + readback = _dict(scorecard.get("readback")) + missing_vmx_aliases = _unique( + _strings(windows99.get("missing_vmx_aliases")) + or _strings(readback.get("windows99_missing_vmx_aliases")) + ) + powered_off_aliases = _unique( + _strings(windows99.get("powered_off_aliases")) + or _strings(readback.get("windows99_powered_off_aliases")) + ) + required_aliases = _unique( + _strings(windows99.get("required_vm_aliases")) or DEFAULT_REQUIRED_ALIASES + ) + package_ready = bool(missing_vmx_aliases or powered_off_aliases) + next_step = ( + MISSING_VMX_NEXT_STEP + if missing_vmx_aliases + else POWERED_OFF_NEXT_STEP + if powered_off_aliases + else "rerun_no_secret_collector_and_reboot_slo_scorecard_verify_only" + ) + + rows = [] + for alias in required_aliases: + candidates = EXPECTED_VMX_CANDIDATES.get(alias, []) + rows.append( + { + "alias": alias, + "missing_vmx_source": alias in missing_vmx_aliases, + "powered_off": alias in powered_off_aliases, + "expected_vmx_candidates": candidates, + "check_mode_probe_commands": [ + _powershell_test_path(candidate) for candidate in candidates + ], + "controlled_restore_required": alias in missing_vmx_aliases, + "controlled_restore_hint": ( + "restore_or_relink_existing_vmx_source_to_expected_path_then_verify" + if alias in missing_vmx_aliases + else "" + ), + } + ) + + return { + "schema_version": "windows99_vmx_source_repair_check_mode_package_v1", + "generated_at": generated_at or _taipei_now(), + "status": ( + "check_mode_package_ready_windows99_vmx_source_repair_required" + if missing_vmx_aliases + else "check_mode_package_ready_windows99_power_check_required" + if powered_off_aliases + else "ready_no_windows99_vmx_source_repair_required" + ), + "package_ready": package_ready, + "target_host_alias": "99", + "scorecard_status": str(scorecard.get("status") or "unknown"), + "scorecard_generated_at": str(scorecard.get("generated_at") or ""), + "collector_status": str( + readback.get("windows99_no_secret_collector_status") or "unknown" + ), + "missing_vmx_aliases": missing_vmx_aliases, + "powered_off_aliases": powered_off_aliases, + "required_vm_aliases": required_aliases, + "vm_rows": rows, + "safe_next_step": next_step, + "controlled_apply_mode": "check_mode_package_only_no_windows_or_vm_write", + "apply_allowed_by_this_package": False, + "post_verifier": [ + "bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh --collect", + "bash scripts/reboot-recovery/install-reboot-auto-recovery-slo-110.sh --verify-only", + "GET /api/v1/agents/reboot-auto-recovery-slo-scorecard", + ], + "forbidden_actions": FORBIDDEN_ACTIONS, + "operation_boundaries": { + "secret_value_read": False, + "remote_write_performed": False, + "vm_power_change_performed": False, + "windows_service_restart_performed": False, + "windows_registry_apply_performed": False, + "host_reboot_performed": False, + }, + } + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Build a no-secret Windows99 VMX source repair check-mode package " + "from a reboot SLO scorecard." + ) + ) + parser.add_argument("--scorecard-file", type=Path) + parser.add_argument("--scorecard-url", default=DEFAULT_SCORECARD_URL) + parser.add_argument("--timeout-seconds", type=float, default=20.0) + parser.add_argument("--generated-at") + parser.add_argument("--output", type=Path) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + scorecard = ( + _load_json_file(args.scorecard_file) + if args.scorecard_file + else _fetch_json_url(args.scorecard_url, args.timeout_seconds) + ) + package = build_package(scorecard, generated_at=args.generated_at) + text = json.dumps(package, ensure_ascii=False, indent=2) + "\n" + if args.output: + args.output.write_text(text, encoding="utf-8") + else: + sys.stdout.write(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/reboot-recovery/tests/test_windows99_vmx_source_repair_package.py b/scripts/reboot-recovery/tests/test_windows99_vmx_source_repair_package.py new file mode 100644 index 000000000..f91e87052 --- /dev/null +++ b/scripts/reboot-recovery/tests/test_windows99_vmx_source_repair_package.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import importlib.util +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ( + ROOT + / "scripts" + / "reboot-recovery" + / "build-windows99-vmx-source-repair-package.py" +) + + +def _load_module(): + spec = importlib.util.spec_from_file_location( + "windows99_vmx_source_repair_package", + SCRIPT, + ) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _scorecard() -> dict: + return { + "status": "blocked_reboot_auto_recovery_slo_not_ready", + "generated_at": "2026-07-03T10:45:28+08:00", + "windows99_vmware_autostart": { + "required_vm_aliases": ["111", "188", "120", "121", "112"], + "missing_vmx_aliases": ["111"], + "powered_off_aliases": ["111", "112", "120", "121", "188"], + }, + "readback": { + "windows99_no_secret_collector_status": ( + "collected_windows99_vmware_verify_stdout" + ) + }, + } + + +def test_package_routes_missing_vmx_to_source_repair_check_mode() -> None: + module = _load_module() + + payload = module.build_package( + _scorecard(), + generated_at="2026-07-03T10:46:00+08:00", + ) + + assert payload["schema_version"] == ( + "windows99_vmx_source_repair_check_mode_package_v1" + ) + assert payload["status"] == ( + "check_mode_package_ready_windows99_vmx_source_repair_required" + ) + assert payload["package_ready"] is True + assert payload["apply_allowed_by_this_package"] is False + assert payload["missing_vmx_aliases"] == ["111"] + assert payload["safe_next_step"] == ( + "restore_windows99_missing_vmx_source_for_aliases_then_rerun_no_secret_" + "collector_and_scorecard_no_vm_power_change" + ) + row_by_alias = {row["alias"]: row for row in payload["vm_rows"]} + assert row_by_alias["111"]["missing_vmx_source"] is True + assert row_by_alias["111"]["controlled_restore_required"] is True + assert row_by_alias["111"]["check_mode_probe_commands"] == [ + ( + "Test-Path -LiteralPath 'D:\\Documents\\Virtual Machines\\" + "192.168.0.111_Ubuntu_64-bit\\192.168.0.111_Ubuntu_64-bit.vmx'" + ) + ] + assert row_by_alias["188"]["missing_vmx_source"] is False + assert payload["operation_boundaries"]["vm_power_change_performed"] is False + assert payload["operation_boundaries"]["secret_value_read"] is False + assert "vm_power_change" in payload["forbidden_actions"] + assert "read_windows_password_or_secret" in payload["forbidden_actions"] + + +def test_package_routes_power_only_to_autostart_check_mode() -> None: + module = _load_module() + scorecard = _scorecard() + scorecard["windows99_vmware_autostart"]["missing_vmx_aliases"] = [] + + payload = module.build_package(scorecard) + + assert payload["status"] == ( + "check_mode_package_ready_windows99_power_check_required" + ) + assert payload["safe_next_step"] == ( + "build_windows99_vmware_autostart_check_mode_package_for_powered_off_" + "aliases_then_rerun_no_secret_collector_no_vm_power_change" + ) + assert payload["vm_rows"][0]["powered_off"] is True + assert payload["apply_allowed_by_this_package"] is False + + +def test_cli_writes_package_file(tmp_path: Path) -> None: + scorecard = tmp_path / "scorecard.json" + output = tmp_path / "package.json" + scorecard.write_text(json.dumps(_scorecard()), encoding="utf-8") + + result = subprocess.run( + [ + "python3.11", + str(SCRIPT), + "--scorecard-file", + str(scorecard), + "--generated-at", + "2026-07-03T10:46:00+08:00", + "--output", + str(output), + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["missing_vmx_aliases"] == ["111"] + assert payload["operation_boundaries"]["remote_write_performed"] is False