fix(reboot): package windows99 vmx source repair check mode
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled
This commit is contained in:
@@ -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 成功
|
||||
|
||||
**完成內容**:
|
||||
|
||||
@@ -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:]))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user