diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 608cb3e27..76a98aa1e 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,18 @@ +## 2026-07-03 — 11:05 Windows99 VMX package CLI 收斂到 scorecard API + +**完成內容**: +- `scripts/reboot-recovery/build-windows99-vmx-source-repair-package.py` 改為優先輸出 production scorecard 內的 `windows99_vmx_source_repair_package`;若 scorecard 尚未帶 package,才呼叫 `reboot_auto_recovery_slo_scorecard` 的 canonical builder 重建。 +- 新增 regression test:CLI 不得另維護一套與 scorecard API 分叉的 package contract。 + +**已跑驗證**: +- `python3.11 -m py_compile apps/api/src/services/reboot_auto_recovery_slo_scorecard.py apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py scripts/reboot-recovery/build-windows99-vmx-source-repair-package.py scripts/reboot-recovery/tests/test_windows99_vmx_source_repair_package.py`:通過。 +- `DATABASE_URL=postgresql+asyncpg://test:test@localhost/test PYTHONPATH=apps/api python3.11 -m pytest apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py scripts/reboot-recovery/tests/test_windows99_vmx_source_repair_package.py -q -p no:cacheprovider`:`23 passed`。 +- `python3.11 ops/runner/guard-gitea-runner-pressure.py --root .`:`GITEA_RUNNER_PRESSURE_GUARD_OK`。 +- `git diff --check`:通過。 + +**仍維持**: +- 未新增獨立 endpoint;canonical production readback 仍是 `/api/v1/agents/reboot-auto-recovery-slo-scorecard` 的 `windows99_vmx_source_repair_package`。未讀 secret / token / `.env` / raw sessions / SQLite / auth;未使用 GitHub / gh;未 workflow_dispatch;未啟動或關閉 VM。 + ## 2026-07-03 — 10:58 Windows99 VMX repair package 接入 API scorecard **完成內容**: diff --git a/scripts/reboot-recovery/build-windows99-vmx-source-repair-package.py b/scripts/reboot-recovery/build-windows99-vmx-source-repair-package.py index 8125369c3..fcb24714d 100644 --- a/scripts/reboot-recovery/build-windows99-vmx-source-repair-package.py +++ b/scripts/reboot-recovery/build-windows99-vmx-source-repair-package.py @@ -12,75 +12,28 @@ 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 + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_API_ROOT = _REPO_ROOT / "apps" / "api" +if str(_API_ROOT) not in sys.path: + sys.path.insert(0, str(_API_ROOT)) + +from src.services.reboot_auto_recovery_slo_scorecard import ( # noqa: E402 + _build_windows99_vmx_source_repair_package, + _taipei_now_iso, +) 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) @@ -97,98 +50,16 @@ def _fetch_json_url(url: str, timeout_seconds: float) -> dict[str, Any]: 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" - ) + existing_package = _dict(scorecard.get("windows99_vmx_source_repair_package")) + if existing_package and generated_at is None: + return existing_package - 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, - }, - } + return _build_windows99_vmx_source_repair_package( + _dict(scorecard.get("windows99_vmware_autostart")), + generated_at=generated_at + or str(scorecard.get("generated_at") or _taipei_now_iso()), + ) def parse_args(argv: list[str]) -> argparse.Namespace: 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 index f91e87052..27e90a65d 100644 --- a/scripts/reboot-recovery/tests/test_windows99_vmx_source_repair_package.py +++ b/scripts/reboot-recovery/tests/test_windows99_vmx_source_repair_package.py @@ -81,6 +81,19 @@ def test_package_routes_missing_vmx_to_source_repair_check_mode() -> None: assert "read_windows_password_or_secret" in payload["forbidden_actions"] +def test_package_prefers_existing_scorecard_package_readback() -> None: + module = _load_module() + scorecard = _scorecard() + scorecard["windows99_vmx_source_repair_package"] = { + "schema_version": "windows99_vmx_source_repair_check_mode_package_v1", + "sentinel": "from_scorecard_api", + } + + payload = module.build_package(scorecard) + + assert payload["sentinel"] == "from_scorecard_api" + + def test_package_routes_power_only_to_autostart_check_mode() -> None: module = _load_module() scorecard = _scorecard()