fix(reboot): surface windows99 vmx repair package
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m13s
CD Pipeline / build-and-deploy (push) Successful in 5m15s
CD Pipeline / post-deploy-checks (push) Successful in 1m51s

This commit is contained in:
Your Name
2026-07-03 11:16:10 +08:00
parent ccc6741c76
commit 92edb3ab8a
8 changed files with 490 additions and 0 deletions

View File

@@ -483,6 +483,9 @@ from src.services.telegram_alert_monitoring_live_receipt_apply import (
apply_latest_telegram_alert_monitoring_live_receipts,
load_latest_telegram_alert_monitoring_live_receipt_apply_readback,
)
from src.services.windows99_vmx_source_repair_package import (
load_latest_windows99_vmx_source_repair_package,
)
router = APIRouter(prefix="/agents", tags=["Agent Teams"])
logger = get_logger("awoooi.agents")
@@ -1532,6 +1535,37 @@ async def get_reboot_auto_recovery_slo_scorecard() -> dict[str, Any]:
) from exc
@router.get(
"/windows99-vmx-source-repair-package",
response_model=dict[str, Any],
summary="取得 Windows99 VMX source repair check-mode package",
description=(
"讀取 P0-006 scorecard 的 Windows99 VMware autostart/VMX blockers"
"產生只讀 check-mode package。此端點只回傳缺 VMX alias、powered-off alias、"
"safe next step、post-verifier 與禁止 runtime action不讀 secret、不 SSH、"
"不啟停 VM、不寫 Windows、不改 registry/task、不重啟主機、不 restart service。"
),
)
async def get_windows99_vmx_source_repair_package() -> dict[str, Any]:
"""回傳 Windows99 VMX source repair check-mode package。"""
try:
payload = await asyncio.to_thread(
load_latest_windows99_vmx_source_repair_package
)
return redact_public_lan_topology(payload)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except (json.JSONDecodeError, ValueError) as exc:
logger.error("windows99_vmx_source_repair_package_invalid", error=str(exc))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Windows99 VMX source repair check-mode package 無效",
) from exc
@router.get(
"/reboot-auto-recovery-drill-preflight",
response_model=dict[str, Any],

View File

@@ -2984,6 +2984,9 @@ def _enrich_from_current_readbacks(
apply_gitea_repo_bundle_backup_readback,
load_latest_reboot_auto_recovery_slo_scorecard,
)
from src.services.windows99_vmx_source_repair_package import (
build_windows99_vmx_source_repair_package,
)
workbench = load_delivery_closure_workbench()
workbench_summary = _dict(workbench.get("summary"))
@@ -3023,6 +3026,9 @@ def _enrich_from_current_readbacks(
windows99_verify_collection_blockers = _strings(
windows99_verify_collection.get("collection_blockers")
)
windows99_vmx_source_repair_package = (
build_windows99_vmx_source_repair_package(reboot_slo)
)
p0_006_event_gated = bool(
reboot_slo_rollups.get("blocked_by_fresh_reboot_window_only") is True
)
@@ -3231,6 +3237,38 @@ def _enrich_from_current_readbacks(
state["windows99_winrm_https_open"] = bool(
windows99_management.get("winrm_https_open") is True
)
state["windows99_vmx_source_repair_package_status"] = str(
windows99_vmx_source_repair_package.get("status") or "unknown"
)
state["windows99_vmx_source_repair_package_ready"] = bool(
windows99_vmx_source_repair_package.get("package_ready") is True
)
state["windows99_vmx_source_repair_missing_aliases"] = _strings(
windows99_vmx_source_repair_package.get("missing_vmx_aliases")
)
state["windows99_vmx_source_repair_powered_off_aliases"] = _strings(
windows99_vmx_source_repair_package.get("powered_off_aliases")
)
state["windows99_vmx_source_repair_controlled_restore_required_count"] = (
_int(
windows99_vmx_source_repair_package.get(
"controlled_restore_required_count"
)
)
)
state["windows99_vmx_source_repair_powered_off_count"] = _int(
windows99_vmx_source_repair_package.get("powered_off_count")
)
state["windows99_vmx_source_repair_safe_next_step"] = str(
windows99_vmx_source_repair_package.get("safe_next_step") or ""
)
state["windows99_vmx_source_repair_controlled_apply_mode"] = str(
windows99_vmx_source_repair_package.get("controlled_apply_mode") or ""
)
state["windows99_vmx_source_repair_apply_allowed_by_this_package"] = bool(
windows99_vmx_source_repair_package.get("apply_allowed_by_this_package")
is True
)
state["stale_snapshot_or_old_cd_runs_must_not_reopen_closed_work"] = True
state["p0_004_template_copy_apply_gate_production_http_status"] = 200
state["p0_004_template_copy_apply_gate_runtime_readback_state"] = (
@@ -3480,6 +3518,33 @@ def _enrich_from_current_readbacks(
]
evidence["windows99_winrm_http_open"] = state["windows99_winrm_http_open"]
evidence["windows99_winrm_https_open"] = state["windows99_winrm_https_open"]
evidence["windows99_vmx_source_repair_package_status"] = state[
"windows99_vmx_source_repair_package_status"
]
evidence["windows99_vmx_source_repair_package_ready"] = state[
"windows99_vmx_source_repair_package_ready"
]
evidence["windows99_vmx_source_repair_missing_aliases"] = state[
"windows99_vmx_source_repair_missing_aliases"
]
evidence["windows99_vmx_source_repair_powered_off_aliases"] = state[
"windows99_vmx_source_repair_powered_off_aliases"
]
evidence["windows99_vmx_source_repair_controlled_restore_required_count"] = (
state["windows99_vmx_source_repair_controlled_restore_required_count"]
)
evidence["windows99_vmx_source_repair_powered_off_count"] = state[
"windows99_vmx_source_repair_powered_off_count"
]
evidence["windows99_vmx_source_repair_safe_next_step"] = state[
"windows99_vmx_source_repair_safe_next_step"
]
evidence["windows99_vmx_source_repair_controlled_apply_mode"] = state[
"windows99_vmx_source_repair_controlled_apply_mode"
]
evidence["windows99_vmx_source_repair_apply_allowed_by_this_package"] = (
state["windows99_vmx_source_repair_apply_allowed_by_this_package"]
)
evidence["drill_preflight_status"] = str(reboot_preflight.get("status") or "")
evidence["drill_preflight_ready"] = (
reboot_preflight_rollups.get("preflight_ready") is True
@@ -4116,6 +4181,19 @@ def _set_rollups_and_summary(
"windows99_console_collection_channels": _strings(
state.get("windows99_console_collection_channels")
),
"windows99_vmx_source_repair_package_ready": (
state.get("windows99_vmx_source_repair_package_ready") is True
),
"windows99_vmx_source_repair_controlled_restore_required_count": _int(
state.get("windows99_vmx_source_repair_controlled_restore_required_count")
),
"windows99_vmx_source_repair_powered_off_count": _int(
state.get("windows99_vmx_source_repair_powered_off_count")
),
"windows99_vmx_source_repair_apply_allowed_by_this_package": (
state.get("windows99_vmx_source_repair_apply_allowed_by_this_package")
is True
),
"p0_004_runtime_readback_ready": p0_004_ready,
"reboot_drill_preflight_runtime_readback_ready": (
state.get("reboot_drill_preflight_runtime_readback_state") == "ready"
@@ -4263,6 +4341,34 @@ def _set_rollups_and_summary(
"windows99_console_collection_channels": _strings(
state.get("windows99_console_collection_channels")
),
"windows99_vmx_source_repair_package_status": str(
state.get("windows99_vmx_source_repair_package_status") or "unknown"
),
"windows99_vmx_source_repair_package_ready": (
state.get("windows99_vmx_source_repair_package_ready") is True
),
"windows99_vmx_source_repair_missing_aliases": _strings(
state.get("windows99_vmx_source_repair_missing_aliases")
),
"windows99_vmx_source_repair_powered_off_aliases": _strings(
state.get("windows99_vmx_source_repair_powered_off_aliases")
),
"windows99_vmx_source_repair_controlled_restore_required_count": _int(
state.get("windows99_vmx_source_repair_controlled_restore_required_count")
),
"windows99_vmx_source_repair_powered_off_count": _int(
state.get("windows99_vmx_source_repair_powered_off_count")
),
"windows99_vmx_source_repair_safe_next_step": str(
state.get("windows99_vmx_source_repair_safe_next_step") or ""
),
"windows99_vmx_source_repair_controlled_apply_mode": str(
state.get("windows99_vmx_source_repair_controlled_apply_mode") or ""
),
"windows99_vmx_source_repair_apply_allowed_by_this_package": (
state.get("windows99_vmx_source_repair_apply_allowed_by_this_package")
is True
),
"windows99_winrm_http_open": (
state.get("windows99_winrm_http_open") is True
),

View File

@@ -0,0 +1,82 @@
"""Windows99 VMX source repair check-mode package readback.
This API-facing wrapper reuses the reboot SLO scorecard's canonical package
builder, then adds compact count/metadata fields for manager UI consumption.
It does not read secrets, start VMs, write Windows state, restart services, or
reboot hosts.
"""
from __future__ import annotations
from typing import Any
from src.services.reboot_auto_recovery_slo_scorecard import (
_build_windows99_vmx_source_repair_package,
_taipei_now_iso,
load_latest_reboot_auto_recovery_slo_scorecard,
)
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 _with_ui_counts(
package: dict[str, Any],
scorecard: dict[str, Any],
) -> dict[str, Any]:
payload = dict(package)
missing_vmx_aliases = _strings(payload.get("missing_vmx_aliases"))
powered_off_aliases = _strings(payload.get("powered_off_aliases"))
readback = _dict(scorecard.get("readback"))
payload["controlled_restore_required_count"] = len(missing_vmx_aliases)
payload["powered_off_count"] = len(powered_off_aliases)
payload["scorecard_status"] = str(scorecard.get("status") or "unknown")
payload["scorecard_generated_at"] = str(scorecard.get("generated_at") or "")
payload["collector_status"] = str(
readback.get("windows99_no_secret_collector_status") or "unknown"
)
return payload
def _alias_lists_match(package: dict[str, Any], windows99: dict[str, Any]) -> bool:
return _strings(package.get("missing_vmx_aliases")) == _strings(
windows99.get("missing_vmx_aliases")
) and _strings(package.get("powered_off_aliases")) == _strings(
windows99.get("powered_off_aliases")
)
def build_windows99_vmx_source_repair_package(
scorecard: dict[str, Any],
*,
generated_at: str | None = None,
) -> dict[str, Any]:
existing_package = _dict(scorecard.get("windows99_vmx_source_repair_package"))
windows99 = _dict(scorecard.get("windows99_vmware_autostart"))
package = (
dict(existing_package)
if existing_package
and generated_at is None
and _alias_lists_match(existing_package, windows99)
else _build_windows99_vmx_source_repair_package(
windows99,
generated_at=generated_at
or str(scorecard.get("generated_at") or _taipei_now_iso()),
)
)
return _with_ui_counts(package, scorecard)
def load_latest_windows99_vmx_source_repair_package() -> dict[str, Any]:
return build_windows99_vmx_source_repair_package(
load_latest_reboot_auto_recovery_slo_scorecard()
)

View File

@@ -620,6 +620,13 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a
"host99_uptime_known": False,
}
)
payload["windows99_vmware_autostart"].update(
{
"required_vm_aliases": ["111", "188", "120", "121", "112"],
"missing_vmx_aliases": ["111"],
"powered_off_aliases": ["111", "112", "120", "121", "188"],
}
)
payload["rollups"].update(
{
"active_blocker_count": len(active_blockers),
@@ -677,6 +684,17 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a
"windows99_vmware_guest_power_not_ready",
"windows99_vmware_vmx_missing",
]
assert state["windows99_vmx_source_repair_package_ready"] is True
assert state["windows99_vmx_source_repair_missing_aliases"] == ["111"]
assert state["windows99_vmx_source_repair_powered_off_count"] == 5
assert (
state["windows99_vmx_source_repair_controlled_restore_required_count"]
== 1
)
assert (
state["windows99_vmx_source_repair_apply_allowed_by_this_package"]
is False
)
assert payload["summary"]["controlled_service_data_backup_blocker_count"] == 0
assert payload["summary"]["active_p0_primary_blocker"] == (
"reboot_event_required_host_unreachable"
@@ -702,6 +720,22 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a
"blocked_reboot_slo_scorecard_host_boot_and_windows99_verify_"
"collection_not_ready"
)
assert payload["summary"]["windows99_vmx_source_repair_package_ready"] is True
assert payload["summary"]["windows99_vmx_source_repair_missing_aliases"] == [
"111"
]
assert (
payload["summary"][
"windows99_vmx_source_repair_controlled_restore_required_count"
]
== 1
)
assert (
payload["rollups"][
"windows99_vmx_source_repair_apply_allowed_by_this_package"
]
is False
)
assert "service/data/backup readback is green" in payload[
"next_execution_order"
][0]
@@ -716,6 +750,19 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a
assert in_progress["evidence"]["next_safe_action"] == (
"rerun_reboot_event_detector_and_host_probe_verify_only_no_reboot"
)
assert (
in_progress["evidence"]["windows99_vmx_source_repair_package_status"]
== "check_mode_package_ready_windows99_vmx_source_repair_required"
)
assert in_progress["evidence"]["windows99_vmx_source_repair_missing_aliases"] == [
"111"
]
assert (
in_progress["evidence"][
"windows99_vmx_source_repair_apply_allowed_by_this_package"
]
is False
)
assert "Windows99 verify collection" in in_progress["professional_fix"][
"action"
]

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.v1 import agents
from src.api.v1.agents import router
from src.services.windows99_vmx_source_repair_package import (
build_windows99_vmx_source_repair_package,
)
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_windows99_vmx_source_repair_package_builder_is_check_mode_only() -> None:
payload = build_windows99_vmx_source_repair_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["missing_vmx_aliases"] == ["111"]
assert payload["powered_off_count"] == 5
assert payload["controlled_restore_required_count"] == 1
assert payload["safe_next_step"] == (
"restore_windows99_missing_vmx_source_for_aliases_then_rerun_no_secret_"
"collector_and_scorecard_no_vm_power_change"
)
assert payload["controlled_apply_mode"] == (
"check_mode_package_only_no_windows_or_vm_write"
)
assert payload["apply_allowed_by_this_package"] is False
assert payload["operation_boundaries"]["secret_value_read"] is False
assert payload["operation_boundaries"]["remote_write_performed"] is False
assert payload["operation_boundaries"]["vm_power_change_performed"] is False
assert "read_windows_password_or_secret" in payload["forbidden_actions"]
assert "vm_power_change" in payload["forbidden_actions"]
def test_windows99_vmx_source_repair_package_endpoint_returns_readback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
agents,
"load_latest_windows99_vmx_source_repair_package",
lambda: build_windows99_vmx_source_repair_package(
_scorecard(),
generated_at="2026-07-03T10:46:00+08:00",
),
)
app = FastAPI()
app.include_router(router, prefix="/api/v1")
client = TestClient(app)
response = client.get("/api/v1/agents/windows99-vmx-source-repair-package")
assert response.status_code == 200
payload = response.json()
assert payload["schema_version"] == (
"windows99_vmx_source_repair_check_mode_package_v1"
)
assert payload["target_host_alias"] == "99"
assert payload["controlled_apply_mode"] == (
"check_mode_package_only_no_windows_or_vm_write"
)
assert payload["apply_allowed_by_this_package"] is False
assert payload["operation_boundaries"]["host_reboot_performed"] is False