fix(reboot): automate windows99 backup search receipt
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m12s
CD Pipeline / build-and-deploy (push) Successful in 7m4s
AWOOOI Harbor 110 Local Repair / workflow-shape (push) Successful in 1s
AWOOOI Harbor 110 Local Repair / harbor-110-local-repair (push) Successful in 19s
CD Pipeline / post-deploy-checks (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 / tests (push) Successful in 2m12s
CD Pipeline / build-and-deploy (push) Successful in 7m4s
AWOOOI Harbor 110 Local Repair / workflow-shape (push) Successful in 1s
AWOOOI Harbor 110 Local Repair / harbor-110-local-repair (push) Successful in 19s
CD Pipeline / post-deploy-checks (push) Has been cancelled
This commit is contained in:
244
scripts/reboot-recovery/build-windows99-vmx-source-backup-search-receipt.py
Executable file
244
scripts/reboot-recovery/build-windows99-vmx-source-backup-search-receipt.py
Executable file
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a public-safe Windows99 VMX backup-search receipt.
|
||||
|
||||
The collector emits no-secret, path-fingerprint-only text. This builder turns
|
||||
that stdout into the JSON receipt consumed by the AWOOOI runtime readback API.
|
||||
It intentionally keeps raw LAN topology, filesystem paths, and fingerprint
|
||||
values out of the public receipt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = "windows99_vmx_source_backup_search_receipt_v1"
|
||||
COLLECTOR_SCHEMA_VERSION = "windows99_vmx_source_backup_search_collector_v1"
|
||||
DEFAULT_OUTPUT = Path("docs/operations/windows99-vmx-source-backup-search-receipt.snapshot.json")
|
||||
FORBIDDEN_FRAGMENTS = ("192.168.0.", "D:\\", "C:\\", "E:\\", "/Users/", "/Volumes/")
|
||||
|
||||
|
||||
def taipei_now_iso() -> str:
|
||||
return datetime.now(timezone(timedelta(hours=8))).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def safe_alias(value: str) -> str:
|
||||
text = value.strip()
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]{1,32}", text):
|
||||
return text
|
||||
return "redacted"
|
||||
|
||||
|
||||
def parse_kv_line(line: str) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for token in line.strip().split():
|
||||
if "=" not in token:
|
||||
continue
|
||||
key, value = token.split("=", 1)
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def parse_collector_stdout(text: str) -> tuple[dict[str, str], list[dict[str, Any]]]:
|
||||
globals_: dict[str, str] = {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("HOST_SEARCH "):
|
||||
kv = parse_kv_line(line[len("HOST_SEARCH ") :])
|
||||
alias = safe_alias(kv.get("alias", "unknown"))
|
||||
status = kv.get("status", "unknown").strip() or "unknown"
|
||||
try:
|
||||
count = int(kv.get("candidate_source_count", "0"))
|
||||
except ValueError:
|
||||
count = 0
|
||||
fingerprints = [
|
||||
part
|
||||
for part in kv.get("path_fingerprints", "").split(",")
|
||||
if re.fullmatch(r"[A-Fa-f0-9]{8,128}|fp[-A-Za-z0-9_]+", part)
|
||||
]
|
||||
known = status in {"searched", "not_matched"}
|
||||
rows.append(
|
||||
{
|
||||
"host_alias": alias,
|
||||
"search_role": "linux_backup_roots",
|
||||
"status": status,
|
||||
"candidate_source_count": max(count, 0),
|
||||
"candidate_source_count_known": known,
|
||||
"candidate_source_fingerprint_count": len(fingerprints),
|
||||
"collector": "collect-windows99-vmx-source-backup-search.sh",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
globals_[key.strip()] = value.strip()
|
||||
return globals_, rows
|
||||
|
||||
|
||||
def locator_row(locator_receipt: dict[str, Any]) -> dict[str, Any]:
|
||||
status = str(locator_receipt.get("locate_status") or "not_collected")
|
||||
known = locator_receipt.get("candidate_source_count_known") is True
|
||||
candidate_count = int(locator_receipt.get("candidate_source_count") or 0) if known else 0
|
||||
fingerprint_count = int(locator_receipt.get("candidate_source_fingerprint_count") or 0)
|
||||
return {
|
||||
"host_alias": "99",
|
||||
"search_role": "windows_vmware_roots_via_locator",
|
||||
"status": status,
|
||||
"candidate_source_count": candidate_count,
|
||||
"candidate_source_count_known": known,
|
||||
"candidate_source_fingerprint_count": fingerprint_count,
|
||||
"collector": "windows99-vmx-source-locator-readback",
|
||||
}
|
||||
|
||||
|
||||
def load_locator_receipt(path: Path | None) -> dict[str, Any]:
|
||||
if path is None or not path.exists():
|
||||
return {}
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def build_receipt(
|
||||
collector_stdout: str,
|
||||
*,
|
||||
generated_at: str,
|
||||
locator_receipt: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
globals_, rows = parse_collector_stdout(collector_stdout)
|
||||
if globals_.get("schema_version") != COLLECTOR_SCHEMA_VERSION:
|
||||
raise ValueError("collector stdout missing expected schema_version")
|
||||
|
||||
host_rows: list[dict[str, Any]] = []
|
||||
if locator_receipt:
|
||||
host_rows.append(locator_row(locator_receipt))
|
||||
host_rows.extend(rows)
|
||||
|
||||
searched_count = sum(
|
||||
1 for row in host_rows if row.get("status") in {"searched", "not_matched"}
|
||||
)
|
||||
blocked_count = sum(
|
||||
1
|
||||
for row in host_rows
|
||||
if str(row.get("status") or "").startswith("blocked_")
|
||||
or row.get("status") == "transport_intermittent"
|
||||
)
|
||||
candidate_count = sum(int(row.get("candidate_source_count") or 0) for row in host_rows)
|
||||
fingerprint_count = sum(
|
||||
int(row.get("candidate_source_fingerprint_count") or 0) for row in host_rows
|
||||
)
|
||||
|
||||
if candidate_count > 0:
|
||||
search_status = "candidate_fingerprint_found_requires_internal_path_resolution"
|
||||
next_step = "resolve_candidate_fingerprint_to_authorized_internal_path"
|
||||
safe_next = (
|
||||
"resolve_candidate_fingerprint_to_authorized_internal_path_then_"
|
||||
"build_scoped_relink_dry_run_no_write"
|
||||
)
|
||||
elif blocked_count > 0:
|
||||
search_status = "blocked_no_verified_vmx_source_found_blocked_hosts_unavailable"
|
||||
next_step = "continue_verified_backup_inventory_search_or_authorized_console_source_path"
|
||||
safe_next = (
|
||||
"continue_verified_backup_inventory_search_or_authorized_console_source_path_"
|
||||
"then_scoped_dry_run_no_write"
|
||||
)
|
||||
else:
|
||||
search_status = "blocked_no_verified_vmx_source_found"
|
||||
next_step = "continue_verified_backup_inventory_search_or_console_source_path"
|
||||
safe_next = (
|
||||
"recover_verified_external_backup_artifact_or_authorized_console_path_"
|
||||
"then_build_scoped_relink_dry_run_no_write"
|
||||
)
|
||||
|
||||
receipt = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"generated_at": generated_at,
|
||||
"receipt_ref": "log://awoooi/p0-006/windows99-vmx-source-backup-search-111",
|
||||
"source": "live_no_secret_filename_search",
|
||||
"collector_readback_present": True,
|
||||
"collector_schema_version": COLLECTOR_SCHEMA_VERSION,
|
||||
"target_host_alias": "99",
|
||||
"target_vm_aliases": ["111"],
|
||||
"search_mode": "filename_path_fingerprint_only",
|
||||
"host_rows": host_rows,
|
||||
"searched_host_count": searched_count,
|
||||
"blocked_host_count": blocked_count,
|
||||
"candidate_source_count": candidate_count,
|
||||
"candidate_source_fingerprint_count": fingerprint_count,
|
||||
"search_status": search_status,
|
||||
"next_executable_step": next_step,
|
||||
"safe_next_step": safe_next,
|
||||
"public_lan_topology_redacted": True,
|
||||
"redacted_public_display_only": True,
|
||||
"raw_path_output": False,
|
||||
"path_fingerprint_only": True,
|
||||
"file_content_read": False,
|
||||
"secret_value_read": False,
|
||||
"password_prompt_allowed": False,
|
||||
"remote_write_performed": False,
|
||||
"host_reboot_performed": False,
|
||||
"vm_power_change_performed": False,
|
||||
"windows_service_restart_performed": False,
|
||||
"windows_registry_apply_performed": False,
|
||||
"scheduled_task_write_performed": False,
|
||||
"km_writeback_ref": "km://awoooi/reboot-slo/windows99-vmx-source-backup-search-111",
|
||||
"playbook_trust_writeback_ref": (
|
||||
"playbook://awoooi/windows99-vmx-source-backup-search/"
|
||||
"windows99-vmx-source-backup-search-111"
|
||||
),
|
||||
"post_search_verifier": [
|
||||
"GET /api/v1/agents/windows99-vmx-source-backup-search-package",
|
||||
"GET /api/v1/agents/windows99-vmx-source-locator-readback",
|
||||
"GET /api/v1/agents/reboot-auto-recovery-slo-scorecard",
|
||||
"GET /api/v1/agents/awoooi-priority-work-order-readback",
|
||||
],
|
||||
}
|
||||
serialized = json.dumps(receipt, ensure_ascii=False)
|
||||
leaked = [fragment for fragment in FORBIDDEN_FRAGMENTS if fragment in serialized]
|
||||
if leaked:
|
||||
raise ValueError(f"unsafe receipt fragments: {leaked}")
|
||||
return receipt
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build the public-safe Windows99 VMX backup-search receipt."
|
||||
)
|
||||
parser.add_argument("--collector-output", type=Path)
|
||||
parser.add_argument("--locator-receipt", type=Path)
|
||||
parser.add_argument("--generated-at", default=taipei_now_iso())
|
||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||
parser.add_argument("--stdout", action="store_true")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
text = (
|
||||
args.collector_output.read_text(encoding="utf-8")
|
||||
if args.collector_output
|
||||
else sys.stdin.read()
|
||||
)
|
||||
receipt = build_receipt(
|
||||
text,
|
||||
generated_at=args.generated_at,
|
||||
locator_receipt=load_locator_receipt(args.locator_receipt),
|
||||
)
|
||||
rendered = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n"
|
||||
if args.stdout:
|
||||
sys.stdout.write(rendered)
|
||||
else:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(rendered, encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
@@ -13,6 +14,12 @@ SCRIPT = (
|
||||
/ "reboot-recovery"
|
||||
/ "collect-windows99-vmx-source-backup-search.sh"
|
||||
)
|
||||
RECEIPT_BUILDER = (
|
||||
ROOT
|
||||
/ "scripts"
|
||||
/ "reboot-recovery"
|
||||
/ "build-windows99-vmx-source-backup-search-receipt.py"
|
||||
)
|
||||
|
||||
|
||||
def _write_executable(path: Path, text: str) -> None:
|
||||
@@ -171,3 +178,78 @@ def test_backup_search_collector_resolves_bare_aliases_and_blocks_unknown(
|
||||
assert "HOST_SEARCH alias=missing status=blocked_host_alias_unresolved" in (
|
||||
result.stdout
|
||||
)
|
||||
|
||||
|
||||
def test_backup_search_receipt_builder_projects_collector_stdout_safely(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
collector_output = tmp_path / "collector.txt"
|
||||
collector_output.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
schema_version=windows99_vmx_source_backup_search_collector_v1
|
||||
search_hosts=110=wooo@192.168.0.110,120=wooo@192.168.0.120
|
||||
secret_value_read=false
|
||||
file_content_read=false
|
||||
raw_path_output=false
|
||||
HOST_SEARCH alias=110 status=blocked_ssh_batchmode_unavailable candidate_source_count=0 path_fingerprints=
|
||||
HOST_SEARCH alias=120 status=searched candidate_source_count=2 path_fingerprints=abc12345,def67890
|
||||
candidate_source_count=2
|
||||
search_status=candidate_fingerprint_found_requires_internal_path_resolution
|
||||
"""
|
||||
).strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
locator_receipt = tmp_path / "locator.json"
|
||||
locator_receipt.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "windows99_vmx_source_locator_receipt_v1",
|
||||
"collector_readback_present": True,
|
||||
"locate_status": "blocked_missing_expected_vmx_source_no_candidate_found",
|
||||
"candidate_source_count_known": True,
|
||||
"candidate_source_count": 0,
|
||||
"candidate_source_fingerprint_count": 0,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
str(RECEIPT_BUILDER),
|
||||
"--collector-output",
|
||||
str(collector_output),
|
||||
"--locator-receipt",
|
||||
str(locator_receipt),
|
||||
"--generated-at",
|
||||
"2026-07-09T17:10:00+08:00",
|
||||
"--stdout",
|
||||
],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["schema_version"] == "windows99_vmx_source_backup_search_receipt_v1"
|
||||
assert payload["collector_readback_present"] is True
|
||||
assert payload["searched_host_count"] == 1
|
||||
assert payload["blocked_host_count"] == 2
|
||||
assert payload["candidate_source_count"] == 2
|
||||
assert payload["candidate_source_fingerprint_count"] == 2
|
||||
assert payload["search_status"] == (
|
||||
"candidate_fingerprint_found_requires_internal_path_resolution"
|
||||
)
|
||||
assert payload["raw_path_output"] is False
|
||||
assert payload["path_fingerprint_only"] is True
|
||||
assert payload["file_content_read"] is False
|
||||
assert payload["secret_value_read"] is False
|
||||
assert payload["remote_write_performed"] is False
|
||||
assert payload["vm_power_change_performed"] is False
|
||||
assert "192.168.0." not in result.stdout
|
||||
assert "D:\\" not in result.stdout
|
||||
assert "/Users/" not in result.stdout
|
||||
|
||||
Reference in New Issue
Block a user