feat(windows99): validate scoped relink dry run
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 1m9s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-10 09:42:29 +08:00
parent 97312fa37a
commit 3afdb61871
3 changed files with 500 additions and 4 deletions

View File

@@ -507,6 +507,7 @@ from src.services.windows99_vmx_source_backup_search_package import (
from src.services.windows99_vmx_source_locator_readback import (
load_latest_windows99_vmx_source_locator_readback,
validate_windows99_vmx_source_candidate_confirmation_packet,
validate_windows99_vmx_source_scoped_relink_dry_run_packet,
)
from src.services.windows99_vmx_source_repair_package import (
load_latest_windows99_vmx_source_repair_package,
@@ -1741,6 +1742,39 @@ async def validate_windows99_vmx_source_candidate_confirmation(
) from exc
@router.post(
"/windows99-vmx-source-locator-readback/validate-scoped-relink-dry-run",
response_model=dict[str, Any],
summary="驗證 Windows99 VMX source scoped relink dry-run packet",
description=(
"針對 P0-006 Windows99 VMX source locator 的 redacted scoped relink "
"dry-run packet 做 no-persist reviewer validation。此端點只判斷 reviewer "
"validation 是否 passed不保存 payload、不回傳 raw VMX path、不讀 VMX "
"內容、不讀 secret、不 SSH、不啟停 VM、不寫 Windows、不改 registry/task、"
"不開 apply gate。"
),
)
async def validate_windows99_vmx_source_scoped_relink_dry_run(
scoped_relink_dry_run_payload: dict[str, Any],
) -> dict[str, Any]:
"""Return no-persist validation for one Windows99 relink dry-run packet."""
try:
payload = await asyncio.to_thread(
validate_windows99_vmx_source_scoped_relink_dry_run_packet,
scoped_relink_dry_run_payload,
)
return redact_public_lan_topology(payload)
except (json.JSONDecodeError, ValueError) as exc:
logger.error(
"windows99_vmx_source_scoped_relink_dry_run_invalid",
error=str(exc),
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Windows99 VMX source scoped relink dry-run 驗證器無效",
) from exc
@router.get(
"/windows99-vmx-source-backup-search-package",
response_model=dict[str, Any],

View File

@@ -38,6 +38,15 @@ _CANDIDATE_CONFIRMATION_PACKET_SCHEMA_VERSION = (
_SCOPED_RELINK_DRY_RUN_SCHEMA_VERSION = (
"windows99_vmx_source_scoped_relink_dry_run_v1"
)
_SCOPED_RELINK_DRY_RUN_PACKET_SCHEMA_VERSION = (
"windows99_vmx_source_scoped_relink_dry_run_packet_v1"
)
_SCOPED_RELINK_DRY_RUN_VALIDATION_SCHEMA_VERSION = (
"windows99_vmx_source_scoped_relink_dry_run_validation_v1"
)
_SCOPED_RELINK_DRY_RUN_RECEIPT_SCHEMA_VERSION = (
"windows99_vmx_source_scoped_relink_dry_run_receipt_v1"
)
_RECEIPT_FILE = "windows99-vmx-source-locator-receipt.snapshot.json"
_COLLECTOR_SCRIPT_PATH = "scripts/reboot-recovery/locate-windows99-vmx-source.sh"
_LOCATOR_SCRIPT_PATH = "scripts/reboot-recovery/windows99-vmx-source-locator.ps1"
@@ -93,6 +102,34 @@ _CANDIDATE_CONFIRMATION_FORBIDDEN_FRAGMENTS = [
"token=",
"secret_value",
]
_SCOPED_RELINK_DRY_RUN_REQUIRED_FIELDS = [
"schema_version",
"plan_id",
"target_host_alias",
"target_vm_aliases",
"candidate_confirmation_accepted",
"candidate_identity_evidence_ref",
"source_fingerprint_ref",
"backup_ref",
"source_of_truth_diff",
"pre_apply_backup_plan",
"rollback_ref",
"post_verifier",
"redacted_metadata_only",
"bounded_identity_metadata_only",
"path_fingerprint_only",
"raw_path_output",
"vmx_file_content_read",
"secret_value_read",
"remote_write_performed",
"runtime_write_performed",
"host_reboot_performed",
"vm_power_change_performed",
"windows_registry_apply_performed",
"scheduled_task_write_performed",
"apply_requested",
"vm_power_change_requested",
]
def _dict(value: Any) -> dict[str, Any]:
@@ -292,6 +329,10 @@ def _build_candidate_confirmation_gate(
}
def _scoped_relink_plan_id(target_aliases: list[str]) -> str:
return f"windows99-vmx-source-relink-dry-run-{','.join(target_aliases) or 'none'}"
def _build_projected_scoped_relink_dry_run_plan(
*,
accepted: bool,
@@ -321,10 +362,7 @@ def _build_projected_scoped_relink_dry_run_plan(
return {
"schema_version": _SCOPED_RELINK_DRY_RUN_SCHEMA_VERSION,
"plan_id": (
"windows99-vmx-source-relink-dry-run-"
f"{','.join(missing_aliases) or 'none'}"
),
"plan_id": _scoped_relink_plan_id(missing_aliases),
"status": (
"ready_for_no_write_review"
if ready
@@ -550,6 +588,234 @@ def validate_windows99_vmx_source_candidate_confirmation_packet(
}
def validate_windows99_vmx_source_scoped_relink_dry_run_packet(
packet: dict[str, Any],
*,
locator_readback: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Validate one redacted scoped relink dry-run packet.
This no-persist validator proves the dry-run is reviewable and bounded. It
does not save payloads, reveal raw VMX paths, read VMX contents, read
secrets, SSH, change VM power state, write Windows state, or open apply.
"""
locator = locator_readback or load_latest_windows99_vmx_source_locator_readback()
current_target_aliases = _strings(locator.get("target_vm_aliases"))
current_missing_aliases = _strings(locator.get("missing_vmx_aliases"))
allowed_target_aliases = current_missing_aliases or current_target_aliases
submitted_aliases = _strings(packet.get("target_vm_aliases"))
submitted_aliases_valid = bool(
submitted_aliases
and allowed_target_aliases
and set(submitted_aliases).issubset(set(allowed_target_aliases))
)
expected_plan_id = _scoped_relink_plan_id(submitted_aliases)
source_of_truth_diff = _dict(packet.get("source_of_truth_diff"))
pre_apply_backup_plan = _dict(packet.get("pre_apply_backup_plan"))
post_verifier = _strings(packet.get("post_verifier"))
missing_fields = [
field
for field in _SCOPED_RELINK_DRY_RUN_REQUIRED_FIELDS
if field not in packet
]
rejected_fields: list[str] = []
forbidden_fragments = _forbidden_fragments(packet)
if packet.get("schema_version") != _SCOPED_RELINK_DRY_RUN_PACKET_SCHEMA_VERSION:
rejected_fields.append("schema_version")
if str(packet.get("plan_id") or "") != expected_plan_id:
rejected_fields.append("plan_id")
if str(packet.get("target_host_alias") or "") != "99":
rejected_fields.append("target_host_alias")
if not submitted_aliases_valid:
rejected_fields.append("target_vm_aliases")
if packet.get("candidate_confirmation_accepted") is not True:
rejected_fields.append("candidate_confirmation_accepted")
if str(packet.get("candidate_identity_evidence_ref") or "").strip() == "":
rejected_fields.append("candidate_identity_evidence_ref")
if str(packet.get("source_fingerprint_ref") or "").strip() == "":
rejected_fields.append("source_fingerprint_ref")
if str(packet.get("backup_ref") or "").strip() == "":
rejected_fields.append("backup_ref")
if not source_of_truth_diff:
rejected_fields.append("source_of_truth_diff")
if source_of_truth_diff.get("raw_path_output", False) is not False:
rejected_fields.append("source_of_truth_diff.raw_path_output")
if source_of_truth_diff.get("path_fingerprint_only") is not True:
rejected_fields.append("source_of_truth_diff.path_fingerprint_only")
if source_of_truth_diff.get("bounded_identity_metadata_only") is not True:
rejected_fields.append("source_of_truth_diff.bounded_identity_metadata_only")
if not pre_apply_backup_plan:
rejected_fields.append("pre_apply_backup_plan")
if pre_apply_backup_plan.get("ready") is not True:
rejected_fields.append("pre_apply_backup_plan.ready")
if str(pre_apply_backup_plan.get("backup_ref") or "").strip() == "":
rejected_fields.append("pre_apply_backup_plan.backup_ref")
if str(packet.get("rollback_ref") or "").startswith("rollback://") is False:
rejected_fields.append("rollback_ref")
if not post_verifier:
rejected_fields.append("post_verifier")
if "GET /api/v1/agents/windows99-vmx-source-locator-readback" not in post_verifier:
rejected_fields.append("post_verifier.locator_readback")
required_true_flags = [
"redacted_metadata_only",
"bounded_identity_metadata_only",
"path_fingerprint_only",
]
required_false_flags = [
"raw_path_output",
"vmx_file_content_read",
"secret_value_read",
"remote_write_performed",
"runtime_write_performed",
"host_reboot_performed",
"vm_power_change_performed",
"windows_registry_apply_performed",
"scheduled_task_write_performed",
"apply_requested",
"vm_power_change_requested",
]
for field in required_true_flags:
if packet.get(field) is not True:
rejected_fields.append(field)
for field in required_false_flags:
if packet.get(field, False) is not False:
rejected_fields.append(field)
if forbidden_fragments:
rejected_fields.append("forbidden_fragments")
accepted = not missing_fields and not rejected_fields
status = (
"accepted_for_reviewer_validation_passed_no_write"
if accepted
else "rejected_scoped_relink_dry_run_packet"
)
next_safe_step = (
"commit_or_attach_no_secret_dry_run_receipt_then_rerun_no_secret_"
"collector_and_scorecard_no_vm_power_change"
if accepted
else "resubmit_redacted_scoped_relink_dry_run_packet_without_raw_paths_or_runtime_actions"
)
plan_id = expected_plan_id if accepted else str(packet.get("plan_id") or "")
source_fingerprint_ref = (
str(packet.get("source_fingerprint_ref") or "").strip() if accepted else ""
)
backup_ref = str(packet.get("backup_ref") or "").strip() if accepted else ""
candidate_identity_evidence_ref = (
str(packet.get("candidate_identity_evidence_ref") or "").strip()
if accepted
else ""
)
rollback_ref = str(packet.get("rollback_ref") or "").strip() if accepted else ""
projected_source_diff = {
"expected_vmx_path_count": locator.get("expected_vmx_path_count"),
"expected_vmx_path_present_count": source_of_truth_diff.get(
"expected_vmx_path_present_count"
)
if accepted
else None,
"candidate_source_count": source_of_truth_diff.get("candidate_source_count")
if accepted
else None,
"candidate_source_fingerprint_count": source_of_truth_diff.get(
"candidate_source_fingerprint_count"
)
if accepted
else None,
"candidate_identity_evidence_ref": candidate_identity_evidence_ref,
"source_fingerprint_ref": source_fingerprint_ref,
"backup_ref": backup_ref,
"raw_path_output": False,
"path_fingerprint_only": True,
"bounded_identity_metadata_only": True,
}
operation_boundaries = {
"no_persist_validator": True,
"request_payload_saved": False,
"secret_value_read": False,
"raw_path_output": False,
"vmx_file_content_read": False,
"password_prompt_allowed": False,
"remote_write_performed": False,
"runtime_write_performed": False,
"host_reboot_performed": False,
"vm_power_change_performed": False,
"windows_registry_apply_performed": False,
"scheduled_task_write_performed": False,
"apply_gate_opened": False,
}
return {
"schema_version": _SCOPED_RELINK_DRY_RUN_VALIDATION_SCHEMA_VERSION,
"status": status,
"accepted": accepted,
"reviewer_validation_passed": accepted,
"controlled_relink_dry_run_ready": accepted,
"target_host_alias": "99",
"target_vm_aliases": current_target_aliases,
"missing_vmx_aliases": current_missing_aliases,
"submitted_target_vm_aliases": submitted_aliases,
"validated_target_vm_aliases": submitted_aliases if accepted else [],
"current_locator_status": str(locator.get("status") or ""),
"current_candidate_confirmation_status": str(
locator.get("candidate_confirmation_status") or ""
),
"plan_id": plan_id,
"expected_plan_id": expected_plan_id,
"source_fingerprint_ref": source_fingerprint_ref,
"backup_ref": backup_ref,
"candidate_identity_evidence_ref": candidate_identity_evidence_ref,
"missing_fields": missing_fields,
"rejected_fields": rejected_fields,
"forbidden_fragment_count": len(forbidden_fragments),
"rollback_ready": accepted,
"post_verifier_ready": accepted,
"pre_apply_backup_plan_ready": accepted,
"km_writeback_ready": accepted,
"playbook_trust_writeback_ready": accepted,
"request_payload_saved": False,
"apply_allowed_by_this_validation": False,
"runtime_write_authorized": False,
"remote_write_performed": False,
"vm_power_change_performed": False,
"secret_value_read": False,
"projected_relink_dry_run_receipt": {
"schema_version": _SCOPED_RELINK_DRY_RUN_RECEIPT_SCHEMA_VERSION,
"receipt_ref": (
f"receipt://awoooi/windows99/{plan_id}" if accepted else ""
),
"ready": accepted,
"status": (
"reviewer_validation_passed_no_write"
if accepted
else "blocked_reviewer_validation_not_passed"
),
"plan_id": plan_id,
"target_selector": {
"host_alias": "99",
"vm_aliases": submitted_aliases if accepted else [],
"missing_vmx_aliases": submitted_aliases if accepted else [],
"secret_collection_allowed": False,
"vm_power_change_allowed": False,
"runtime_write_allowed": False,
},
"source_of_truth_diff": projected_source_diff,
"pre_apply_backup_plan": {
"ready": accepted,
"backup_ref": backup_ref,
"destructive_restore_allowed": False,
},
"rollback_ref": rollback_ref,
"post_verifier": post_verifier if accepted else [],
"operation_boundaries": operation_boundaries,
},
"operation_boundaries": operation_boundaries,
"next_safe_step": next_safe_step,
}
def build_windows99_vmx_source_locator_readback(
scorecard: dict[str, Any],
*,

View File

@@ -14,6 +14,7 @@ from src.api.v1.agents import router
from src.services.windows99_vmx_source_locator_readback import (
build_windows99_vmx_source_locator_readback,
validate_windows99_vmx_source_candidate_confirmation_packet,
validate_windows99_vmx_source_scoped_relink_dry_run_packet,
)
from src.services.windows99_vmx_source_repair_package import (
build_windows99_vmx_source_repair_package,
@@ -53,6 +54,85 @@ def _scorecard() -> dict:
}
def _locator_ready_for_scoped_relink_dry_run() -> dict:
repair_package = build_windows99_vmx_source_repair_package(
_scorecard(),
generated_at="2026-07-03T11:21:00+08:00",
)
return build_windows99_vmx_source_locator_readback(
_scorecard(),
repair_package=repair_package,
collector_receipt={
"schema_version": "windows99_vmx_source_locator_receipt_v1",
"collector_readback_present": True,
"collector_collection_status": (
"collected_windows99_vmx_source_locator_stdout"
),
"target_host_alias": "99",
"target_vm_aliases": ["111"],
"expected_vmx_path_present_count": 0,
"candidate_source_count": 1,
"candidate_source_fingerprint_count": 1,
"alias_hint_candidate_count": 0,
"unassigned_ubuntu_candidate_count": 0,
"identity_metadata_read": True,
"bounded_identity_metadata_only": True,
"identity_target_hint_count": 1,
"identity_unassigned_ubuntu_candidate_count": 0,
"locate_status": "single_identity_candidate_requires_confirmation",
},
)
def _valid_scoped_relink_dry_run_packet() -> dict:
return {
"schema_version": "windows99_vmx_source_scoped_relink_dry_run_packet_v1",
"plan_id": "windows99-vmx-source-relink-dry-run-111",
"target_host_alias": "99",
"target_vm_aliases": ["111"],
"candidate_confirmation_accepted": True,
"candidate_identity_evidence_ref": (
"console://awoooi/windows99/candidate-identity-redacted-111"
),
"source_fingerprint_ref": "fingerprint://awoooi/windows99/vmx-source-111",
"backup_ref": "backup://awoooi/windows99/pre-relink-redacted-111",
"source_of_truth_diff": {
"expected_vmx_path_present_count": 0,
"candidate_source_count": 1,
"candidate_source_fingerprint_count": 1,
"raw_path_output": False,
"path_fingerprint_only": True,
"bounded_identity_metadata_only": True,
},
"pre_apply_backup_plan": {
"ready": True,
"backup_ref": "backup://awoooi/windows99/pre-relink-redacted-111",
"destructive_restore_allowed": False,
},
"rollback_ref": "rollback://awoooi/windows99-vmx-source-relink-dry-run",
"post_verifier": [
"bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh --collect",
"GET /api/v1/agents/reboot-auto-recovery-slo-scorecard",
"GET /api/v1/agents/windows99-vmx-source-repair-package",
"GET /api/v1/agents/windows99-vmx-source-locator-readback",
],
"redacted_metadata_only": True,
"bounded_identity_metadata_only": True,
"path_fingerprint_only": True,
"raw_path_output": False,
"vmx_file_content_read": False,
"secret_value_read": False,
"remote_write_performed": False,
"runtime_write_performed": False,
"host_reboot_performed": False,
"vm_power_change_performed": False,
"windows_registry_apply_performed": False,
"scheduled_task_write_performed": False,
"apply_requested": False,
"vm_power_change_requested": False,
}
def test_windows99_vmx_source_locator_builder_is_no_secret_check_mode() -> None:
repair_package = build_windows99_vmx_source_repair_package(
_scorecard(),
@@ -442,6 +522,78 @@ def test_windows99_candidate_confirmation_validator_rejects_sensitive_payload()
assert payload["operation_boundaries"]["apply_gate_opened"] is False
def test_windows99_scoped_relink_dry_run_validator_accepts_redacted_packet() -> None:
payload = validate_windows99_vmx_source_scoped_relink_dry_run_packet(
_valid_scoped_relink_dry_run_packet(),
locator_readback=_locator_ready_for_scoped_relink_dry_run(),
)
assert payload["schema_version"] == (
"windows99_vmx_source_scoped_relink_dry_run_validation_v1"
)
assert payload["status"] == "accepted_for_reviewer_validation_passed_no_write"
assert payload["accepted"] is True
assert payload["reviewer_validation_passed"] is True
assert payload["controlled_relink_dry_run_ready"] is True
assert payload["validated_target_vm_aliases"] == ["111"]
assert payload["plan_id"] == "windows99-vmx-source-relink-dry-run-111"
assert payload["rollback_ready"] is True
assert payload["post_verifier_ready"] is True
assert payload["pre_apply_backup_plan_ready"] is True
assert payload["apply_allowed_by_this_validation"] is False
assert payload["runtime_write_authorized"] is False
assert payload["request_payload_saved"] is False
assert payload["remote_write_performed"] is False
assert payload["vm_power_change_performed"] is False
assert payload["secret_value_read"] is False
receipt = payload["projected_relink_dry_run_receipt"]
assert receipt["schema_version"] == (
"windows99_vmx_source_scoped_relink_dry_run_receipt_v1"
)
assert receipt["ready"] is True
assert receipt["target_selector"]["host_alias"] == "99"
assert receipt["target_selector"]["vm_aliases"] == ["111"]
assert receipt["target_selector"]["runtime_write_allowed"] is False
assert receipt["source_of_truth_diff"]["raw_path_output"] is False
assert receipt["source_of_truth_diff"]["path_fingerprint_only"] is True
assert receipt["pre_apply_backup_plan"]["ready"] is True
assert receipt["pre_apply_backup_plan"]["destructive_restore_allowed"] is False
assert "GET /api/v1/agents/windows99-vmx-source-locator-readback" in receipt[
"post_verifier"
]
assert receipt["operation_boundaries"]["request_payload_saved"] is False
assert receipt["operation_boundaries"]["remote_write_performed"] is False
assert receipt["operation_boundaries"]["vm_power_change_performed"] is False
def test_windows99_scoped_relink_dry_run_validator_rejects_sensitive_payload() -> None:
packet = _valid_scoped_relink_dry_run_packet()
packet["source_fingerprint_ref"] = "D:\\raw\\path\\vm.vmx"
packet["source_of_truth_diff"]["raw_path_output"] = True
packet["vmx_file_content_read"] = True
payload = validate_windows99_vmx_source_scoped_relink_dry_run_packet(
packet,
locator_readback=_locator_ready_for_scoped_relink_dry_run(),
)
assert payload["accepted"] is False
assert payload["status"] == "rejected_scoped_relink_dry_run_packet"
assert payload["reviewer_validation_passed"] is False
assert payload["controlled_relink_dry_run_ready"] is False
assert "source_of_truth_diff.raw_path_output" in payload["rejected_fields"]
assert "vmx_file_content_read" in payload["rejected_fields"]
assert "forbidden_fragments" in payload["rejected_fields"]
assert payload["source_fingerprint_ref"] == ""
assert payload["backup_ref"] == ""
assert payload["projected_relink_dry_run_receipt"]["ready"] is False
assert payload["projected_relink_dry_run_receipt"]["post_verifier"] == []
serialized = json.dumps(payload)
assert "D:\\" not in serialized
assert "192.168.0." not in serialized
assert payload["operation_boundaries"]["apply_gate_opened"] is False
def test_windows99_vmx_source_locator_endpoint_returns_public_safe_readback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -578,3 +730,47 @@ def test_windows99_candidate_confirmation_endpoint_is_no_persist_validator(
assert plan["request_payload_saved"] is False
assert "D:\\" not in response.text
assert "192.168.0." not in response.text
def test_windows99_scoped_relink_dry_run_endpoint_is_no_persist_validator(
monkeypatch: pytest.MonkeyPatch,
) -> None:
locator = _locator_ready_for_scoped_relink_dry_run()
monkeypatch.setattr(
agents,
"validate_windows99_vmx_source_scoped_relink_dry_run_packet",
lambda packet: validate_windows99_vmx_source_scoped_relink_dry_run_packet(
packet,
locator_readback=locator,
),
)
app = FastAPI()
app.include_router(router, prefix="/api/v1")
client = TestClient(app)
response = client.post(
"/api/v1/agents/windows99-vmx-source-locator-readback/"
"validate-scoped-relink-dry-run",
json=_valid_scoped_relink_dry_run_packet(),
)
assert response.status_code == 200
payload = response.json()
assert payload["schema_version"] == (
"windows99_vmx_source_scoped_relink_dry_run_validation_v1"
)
assert payload["accepted"] is True
assert payload["reviewer_validation_passed"] is True
assert payload["controlled_relink_dry_run_ready"] is True
assert payload["operation_boundaries"]["no_persist_validator"] is True
assert payload["operation_boundaries"]["request_payload_saved"] is False
assert payload["operation_boundaries"]["apply_gate_opened"] is False
assert payload["operation_boundaries"]["vm_power_change_performed"] is False
receipt = payload["projected_relink_dry_run_receipt"]
assert receipt["ready"] is True
assert receipt["target_selector"]["vm_aliases"] == ["111"]
assert receipt["pre_apply_backup_plan"]["destructive_restore_allowed"] is False
assert payload["apply_allowed_by_this_validation"] is False
assert payload["runtime_write_authorized"] is False
assert "D:\\" not in response.text
assert "192.168.0." not in response.text