8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
1063 lines
38 KiB
Python
1063 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
"""Prepare and verify a G8 NAS-preview runtime rollback control-plane probe.
|
|
|
|
This harness has two phases:
|
|
|
|
``prepare``
|
|
Build a deterministic, secret-free release-gate request, human rollback
|
|
approval, expected executor command, and executor manifest. The manifest
|
|
uses ``vignette.nas-preview-rollback-manifest.v1`` (executor schema d31b0d).
|
|
|
|
``execute``
|
|
Send the prepared requests to a separately deployed control-plane API and
|
|
verify the durable ``executed`` lifecycle receipt through the read model.
|
|
|
|
The main preview API must never authorize its own rollback. ``execute``
|
|
therefore requires both control-plane and preview base URLs and rejects equal
|
|
origins. A distinct control-plane API, with its own process lifecycle and an
|
|
enabled rollback executor, is a hard prerequisite. Tokens, cookies, login
|
|
identity, and raw HTTP response bodies are never printed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from http.cookiejar import CookieJar
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
from urllib.parse import urlsplit
|
|
from uuid import UUID, uuid5
|
|
|
|
|
|
PLAN_SCHEMA = "vignette.g8-rollback-control-plane-probe-plan.v1"
|
|
RESULT_SCHEMA = "vignette.g8-rollback-control-plane-probe-result.v1"
|
|
EXECUTOR_REQUEST_SCHEMA = "oas.rollback-executor.v1"
|
|
EXECUTOR_MANIFEST_SCHEMA = "vignette.nas-preview-rollback-manifest.v1"
|
|
DATA_CLASSIFICATION = "synthetic_replay_red_team_coverage_drift"
|
|
INTERNAL_TOKEN_HEADER = "X-Vignette-Continuous-Improvement-Token"
|
|
|
|
TARGET_PROJECT = "vignette-preview-20260807"
|
|
TARGET_ROOT = "/volume1/docker/vignette-preview-20260807"
|
|
TARGET_SERVICES = ["api", "web"]
|
|
TARGET_HEALTH_URL = "http://127.0.0.1:8088/api/health"
|
|
|
|
RELEASE_GATE_PATH = "/internal/continuous-improvement/release-gates"
|
|
APPROVAL_PATH = "/continuous-improvement/approvals"
|
|
READ_MODEL_PATH = "/internal/continuous-improvement"
|
|
DEV_LOGIN_PATH = "/auth/dev-login"
|
|
|
|
UUID_NAMESPACE = UUID("b9ecf1e8-8d40-5e3d-9ab2-c0d4bb842496")
|
|
SUFFIX_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
|
|
IMAGE_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
|
|
ARTIFACT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$")
|
|
RELEASE_ID_RE = re.compile(r"^oas-g8-release-[a-z0-9-]+$")
|
|
PROVENANCE_RE = re.compile(r"^(repo|db|audit)://[a-zA-Z0-9_./:-]+$")
|
|
SAFE_REF_PREFIXES = ("https://", "audit://", "db://", "repo://")
|
|
SAFE_EVIDENCE_RE = re.compile(r"^(?:https|audit|db|repo)://[A-Za-z0-9._:/-]+$")
|
|
MAX_RESPONSE_BYTES = 1_048_576
|
|
|
|
PLAN_FIELDS = frozenset(
|
|
{
|
|
"schema_version",
|
|
"probe_id",
|
|
"suffix",
|
|
"plan_sha256",
|
|
"release_gate",
|
|
"approval",
|
|
"expected_executor_request",
|
|
"executor_manifest",
|
|
"safety",
|
|
}
|
|
)
|
|
RESULT_FIELDS = frozenset(
|
|
{
|
|
"schema_version",
|
|
"probe_id",
|
|
"plan_sha256",
|
|
"release_gate_id",
|
|
"approval_event_id",
|
|
"lifecycle_event_id",
|
|
"lifecycle_status",
|
|
"artifact_record_id",
|
|
"artifact_id",
|
|
"artifact_sha256",
|
|
"executor_receipt_id",
|
|
"executor_evidence_refs",
|
|
"release_gate_idempotent_replay",
|
|
"approval_idempotent_replay",
|
|
"binding_verified",
|
|
"control_plane_separation_verified",
|
|
"contains_secrets",
|
|
"contains_pii",
|
|
}
|
|
)
|
|
|
|
|
|
class ProbeError(RuntimeError):
|
|
"""Fail-closed error with a non-sensitive stable code."""
|
|
|
|
|
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(
|
|
self,
|
|
req: urllib.request.Request,
|
|
fp: Any,
|
|
code: int,
|
|
msg: str,
|
|
headers: Any,
|
|
newurl: str,
|
|
) -> None:
|
|
return None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ApiResponse:
|
|
status: int
|
|
body: Any
|
|
|
|
|
|
def _canonical_bytes(value: Any) -> bytes:
|
|
return json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
).encode("utf-8")
|
|
|
|
|
|
def _sha256_json(value: Any) -> str:
|
|
return hashlib.sha256(_canonical_bytes(value)).hexdigest()
|
|
|
|
|
|
def _manifest_bytes(value: Any) -> bytes:
|
|
return _canonical_bytes(value) + b"\n"
|
|
|
|
|
|
def _stable_uuid(suffix: str, label: str) -> str:
|
|
return str(uuid5(UUID_NAMESPACE, f"{suffix}:{label}"))
|
|
|
|
|
|
def _require_exact_dict(value: Any, fields: Iterable[str], code: str) -> dict[str, Any]:
|
|
if not isinstance(value, dict) or set(value) != set(fields):
|
|
raise ProbeError(code)
|
|
return value
|
|
|
|
|
|
def _require_string(value: Any, code: str, *, maximum: int = 500) -> str:
|
|
if not isinstance(value, str) or not value or len(value) > maximum:
|
|
raise ProbeError(code)
|
|
return value
|
|
|
|
|
|
def _require_uuid(value: Any, code: str) -> str:
|
|
text = _require_string(value, code, maximum=36)
|
|
try:
|
|
parsed = UUID(text)
|
|
except ValueError as exc:
|
|
raise ProbeError(code) from exc
|
|
if str(parsed) != text:
|
|
raise ProbeError(code)
|
|
return text
|
|
|
|
|
|
def _require_sha256(value: Any, code: str) -> str:
|
|
if not isinstance(value, str) or SHA256_RE.fullmatch(value) is None:
|
|
raise ProbeError(code)
|
|
return value
|
|
|
|
|
|
def _require_image(value: Any, code: str) -> str:
|
|
if not isinstance(value, str) or IMAGE_RE.fullmatch(value) is None:
|
|
raise ProbeError(code)
|
|
return value
|
|
|
|
|
|
def _require_artifact_id(value: Any) -> str:
|
|
text = _require_string(value, "invalid_artifact_id", maximum=180)
|
|
if ARTIFACT_ID_RE.fullmatch(text) is None:
|
|
raise ProbeError("invalid_artifact_id")
|
|
return text
|
|
|
|
|
|
def _require_provenance(value: Any) -> str:
|
|
text = _require_string(value, "invalid_artifact_provenance_uri")
|
|
if PROVENANCE_RE.fullmatch(text) is None:
|
|
raise ProbeError("invalid_artifact_provenance_uri")
|
|
return text
|
|
|
|
|
|
def _require_evidence_refs(value: Any) -> list[str]:
|
|
if not isinstance(value, list) or not 1 <= len(value) <= 100:
|
|
raise ProbeError("invalid_evidence_refs")
|
|
refs: list[str] = []
|
|
for item in value:
|
|
ref = _require_string(item, "invalid_evidence_ref")
|
|
if (
|
|
not ref.startswith(SAFE_REF_PREFIXES)
|
|
or SAFE_EVIDENCE_RE.fullmatch(ref) is None
|
|
):
|
|
raise ProbeError("invalid_evidence_ref")
|
|
refs.append(ref)
|
|
if len(refs) != len(set(refs)):
|
|
raise ProbeError("duplicate_evidence_refs")
|
|
return refs
|
|
|
|
|
|
def _artifact(
|
|
*,
|
|
suffix: str,
|
|
kind: str,
|
|
artifact_id: str | None = None,
|
|
artifact_sha256: str | None = None,
|
|
provenance_uri: str | None = None,
|
|
) -> dict[str, str]:
|
|
return {
|
|
"artifact_record_id": _stable_uuid(suffix, f"artifact:{kind}"),
|
|
"artifact_id": artifact_id or f"g8-nas-probe-{kind}-{suffix}",
|
|
"content_sha256": artifact_sha256
|
|
or hashlib.sha256(f"{suffix}:{kind}".encode("ascii")).hexdigest(),
|
|
"provenance_uri": provenance_uri
|
|
or f"audit://vignette-nas-preview/g8-rollback-probe/{suffix}/{kind}",
|
|
}
|
|
|
|
|
|
def build_plan(
|
|
*,
|
|
suffix: str,
|
|
artifact_id: str,
|
|
artifact_sha256: str,
|
|
artifact_provenance_uri: str,
|
|
evidence_refs: list[str],
|
|
compose_sha256: str,
|
|
env_sha256: str,
|
|
resolved_config_sha256: str,
|
|
api_image: str,
|
|
web_image: str,
|
|
release_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
if SUFFIX_RE.fullmatch(suffix) is None:
|
|
raise ProbeError("invalid_suffix")
|
|
artifact_id = _require_artifact_id(artifact_id)
|
|
artifact_sha256 = _require_sha256(artifact_sha256, "invalid_artifact_sha256")
|
|
artifact_provenance_uri = _require_provenance(artifact_provenance_uri)
|
|
evidence_refs = _require_evidence_refs(evidence_refs)
|
|
compose_sha256 = _require_sha256(compose_sha256, "invalid_compose_sha256")
|
|
env_sha256 = _require_sha256(env_sha256, "invalid_env_sha256")
|
|
resolved_config_sha256 = _require_sha256(
|
|
resolved_config_sha256, "invalid_resolved_config_sha256"
|
|
)
|
|
api_image = _require_image(api_image, "invalid_api_image")
|
|
web_image = _require_image(web_image, "invalid_web_image")
|
|
|
|
release_id = release_id or f"oas-g8-release-nas-rollback-{suffix}"
|
|
if (
|
|
not isinstance(release_id, str)
|
|
or len(release_id) > 180
|
|
or RELEASE_ID_RE.fullmatch(release_id) is None
|
|
):
|
|
raise ProbeError("invalid_release_id")
|
|
|
|
release_submission_id = _stable_uuid(suffix, "release-submission")
|
|
gate_id = _stable_uuid(suffix, "release-gate")
|
|
approval_submission_id = _stable_uuid(suffix, "approval-submission")
|
|
approval_event_id = _stable_uuid(suffix, "approval-event")
|
|
effect_record_id = _stable_uuid(suffix, "effect-record")
|
|
rollback_artifact = _artifact(
|
|
suffix=suffix,
|
|
kind="rollback",
|
|
artifact_id=artifact_id,
|
|
artifact_sha256=artifact_sha256,
|
|
provenance_uri=artifact_provenance_uri,
|
|
)
|
|
artifacts = {
|
|
"baseline": _artifact(suffix=suffix, kind="baseline"),
|
|
"threshold": _artifact(suffix=suffix, kind="threshold"),
|
|
"provenance": [_artifact(suffix=suffix, kind="provenance")],
|
|
"rollback": rollback_artifact,
|
|
}
|
|
release_payload = {
|
|
"submission_id": release_submission_id,
|
|
"gate_id": gate_id,
|
|
"data_classification": DATA_CLASSIFICATION,
|
|
"manifest": {
|
|
"release_id": release_id,
|
|
"red_green_passed": True,
|
|
"contract_passed": True,
|
|
"e2e_passed": True,
|
|
"runtime_proof_passed": True,
|
|
"public_proof_passed": True,
|
|
"ssot_synced": True,
|
|
"evidence_refs": evidence_refs,
|
|
},
|
|
"artifacts": artifacts,
|
|
}
|
|
approval_payload = {
|
|
"submission_id": approval_submission_id,
|
|
"approval_event_id": approval_event_id,
|
|
"effect_record_id": effect_record_id,
|
|
"target_kind": "release_gate",
|
|
"target_id": gate_id,
|
|
"decision": "authorize_rollback",
|
|
"reason_code": "nas_preview_runtime_rollback_probe",
|
|
"evidence_refs": evidence_refs,
|
|
}
|
|
expected_executor_request = {
|
|
"schema_version": EXECUTOR_REQUEST_SCHEMA,
|
|
"idempotency_key": effect_record_id,
|
|
"approval_event_id": approval_event_id,
|
|
"rollback_scope": "runtime",
|
|
"target_kind": "release_gate",
|
|
"target_id": gate_id,
|
|
"subject_id": release_id,
|
|
"rollback_target_id": artifact_id,
|
|
"artifact_record_id": rollback_artifact["artifact_record_id"],
|
|
"artifact_id": artifact_id,
|
|
"artifact_sha256": artifact_sha256,
|
|
"artifact_provenance_uri": artifact_provenance_uri,
|
|
"authorization_evidence_refs": evidence_refs,
|
|
}
|
|
runtime_root = f"{TARGET_ROOT}/.rollback-executor/runtime/{artifact_id}"
|
|
manifest_artifact = {
|
|
"idempotency_key": effect_record_id,
|
|
"approval_event_id": approval_event_id,
|
|
"authorization_evidence_refs": evidence_refs,
|
|
"artifact_id": artifact_id,
|
|
"artifact_sha256": artifact_sha256,
|
|
"artifact_record_id": rollback_artifact["artifact_record_id"],
|
|
"artifact_provenance_uri": artifact_provenance_uri,
|
|
"target_id": gate_id,
|
|
"subject_id": release_id,
|
|
"rollback_target_id": artifact_id,
|
|
"compose_file": f"{runtime_root}/docker-compose.yml",
|
|
"compose_sha256": compose_sha256,
|
|
"env_file": f"{runtime_root}/.env",
|
|
"env_sha256": env_sha256,
|
|
"resolved_config_sha256": resolved_config_sha256,
|
|
"api_image": api_image,
|
|
"web_image": web_image,
|
|
}
|
|
manifest_payload = {
|
|
"schema_version": EXECUTOR_MANIFEST_SCHEMA,
|
|
"target": {
|
|
"compose_project": TARGET_PROJECT,
|
|
"remote_root": TARGET_ROOT,
|
|
"services": TARGET_SERVICES,
|
|
"health_url": TARGET_HEALTH_URL,
|
|
},
|
|
"artifacts": [manifest_artifact],
|
|
}
|
|
core: dict[str, Any] = {
|
|
"schema_version": PLAN_SCHEMA,
|
|
"probe_id": f"nas-g8-probe-{hashlib.sha256(suffix.encode('ascii')).hexdigest()[:24]}",
|
|
"suffix": suffix,
|
|
"release_gate": {"path": RELEASE_GATE_PATH, "payload": release_payload},
|
|
"approval": {"path": APPROVAL_PATH, "payload": approval_payload},
|
|
"expected_executor_request": expected_executor_request,
|
|
"executor_manifest": {
|
|
"file_sha256": hashlib.sha256(
|
|
_manifest_bytes(manifest_payload)
|
|
).hexdigest(),
|
|
"payload": manifest_payload,
|
|
},
|
|
"safety": {
|
|
"control_plane_required": True,
|
|
"main_preview_self_rollback_forbidden": True,
|
|
"contains_secrets": False,
|
|
"contains_pii": False,
|
|
},
|
|
}
|
|
plan = {**core, "plan_sha256": _sha256_json(core)}
|
|
return validate_plan(plan)
|
|
|
|
|
|
def _validate_gate_artifact(value: Any, code: str) -> dict[str, Any]:
|
|
artifact = _require_exact_dict(
|
|
value,
|
|
{"artifact_record_id", "artifact_id", "content_sha256", "provenance_uri"},
|
|
code,
|
|
)
|
|
_require_uuid(artifact["artifact_record_id"], code)
|
|
_require_artifact_id(artifact["artifact_id"])
|
|
_require_sha256(artifact["content_sha256"], code)
|
|
_require_provenance(artifact["provenance_uri"])
|
|
return artifact
|
|
|
|
|
|
def validate_plan(value: Any) -> dict[str, Any]:
|
|
plan = _require_exact_dict(value, PLAN_FIELDS, "invalid_plan_shape")
|
|
if plan["schema_version"] != PLAN_SCHEMA:
|
|
raise ProbeError("invalid_plan_schema")
|
|
suffix = _require_string(plan["suffix"], "invalid_suffix", maximum=64)
|
|
if SUFFIX_RE.fullmatch(suffix) is None:
|
|
raise ProbeError("invalid_suffix")
|
|
expected_probe_id = (
|
|
f"nas-g8-probe-{hashlib.sha256(suffix.encode('ascii')).hexdigest()[:24]}"
|
|
)
|
|
if plan["probe_id"] != expected_probe_id:
|
|
raise ProbeError("probe_id_mismatch")
|
|
supplied_digest = _require_sha256(plan["plan_sha256"], "invalid_plan_sha256")
|
|
core = {key: item for key, item in plan.items() if key != "plan_sha256"}
|
|
if supplied_digest != _sha256_json(core):
|
|
raise ProbeError("plan_sha256_mismatch")
|
|
|
|
release = _require_exact_dict(
|
|
plan["release_gate"], {"path", "payload"}, "invalid_release_gate_shape"
|
|
)
|
|
approval = _require_exact_dict(
|
|
plan["approval"], {"path", "payload"}, "invalid_approval_shape"
|
|
)
|
|
if release["path"] != RELEASE_GATE_PATH or approval["path"] != APPROVAL_PATH:
|
|
raise ProbeError("invalid_api_path")
|
|
release_payload = _require_exact_dict(
|
|
release["payload"],
|
|
{"submission_id", "gate_id", "data_classification", "manifest", "artifacts"},
|
|
"invalid_release_payload_shape",
|
|
)
|
|
_require_uuid(release_payload["submission_id"], "invalid_release_submission_id")
|
|
gate_id = _require_uuid(release_payload["gate_id"], "invalid_gate_id")
|
|
if release_payload["data_classification"] != DATA_CLASSIFICATION:
|
|
raise ProbeError("invalid_data_classification")
|
|
manifest = _require_exact_dict(
|
|
release_payload["manifest"],
|
|
{
|
|
"release_id",
|
|
"red_green_passed",
|
|
"contract_passed",
|
|
"e2e_passed",
|
|
"runtime_proof_passed",
|
|
"public_proof_passed",
|
|
"ssot_synced",
|
|
"evidence_refs",
|
|
},
|
|
"invalid_release_manifest_shape",
|
|
)
|
|
release_id = _require_string(
|
|
manifest["release_id"], "invalid_release_id", maximum=180
|
|
)
|
|
if RELEASE_ID_RE.fullmatch(release_id) is None:
|
|
raise ProbeError("invalid_release_id")
|
|
for field in (
|
|
"red_green_passed",
|
|
"contract_passed",
|
|
"e2e_passed",
|
|
"runtime_proof_passed",
|
|
"public_proof_passed",
|
|
"ssot_synced",
|
|
):
|
|
if manifest[field] is not True:
|
|
raise ProbeError("release_gate_not_qualified")
|
|
release_evidence = _require_evidence_refs(manifest["evidence_refs"])
|
|
artifacts = _require_exact_dict(
|
|
release_payload["artifacts"],
|
|
{"baseline", "threshold", "provenance", "rollback"},
|
|
"invalid_gate_artifacts_shape",
|
|
)
|
|
_validate_gate_artifact(artifacts["baseline"], "invalid_baseline_artifact")
|
|
_validate_gate_artifact(artifacts["threshold"], "invalid_threshold_artifact")
|
|
if (
|
|
not isinstance(artifacts["provenance"], list)
|
|
or len(artifacts["provenance"]) != 1
|
|
):
|
|
raise ProbeError("invalid_provenance_artifacts")
|
|
_validate_gate_artifact(artifacts["provenance"][0], "invalid_provenance_artifact")
|
|
rollback_artifact = _validate_gate_artifact(
|
|
artifacts["rollback"], "invalid_rollback_artifact"
|
|
)
|
|
record_ids = [
|
|
artifacts["baseline"]["artifact_record_id"],
|
|
artifacts["threshold"]["artifact_record_id"],
|
|
artifacts["provenance"][0]["artifact_record_id"],
|
|
rollback_artifact["artifact_record_id"],
|
|
]
|
|
if len(record_ids) != len(set(record_ids)):
|
|
raise ProbeError("duplicate_artifact_record_ids")
|
|
|
|
approval_payload = _require_exact_dict(
|
|
approval["payload"],
|
|
{
|
|
"submission_id",
|
|
"approval_event_id",
|
|
"effect_record_id",
|
|
"target_kind",
|
|
"target_id",
|
|
"decision",
|
|
"reason_code",
|
|
"evidence_refs",
|
|
},
|
|
"invalid_approval_payload_shape",
|
|
)
|
|
_require_uuid(approval_payload["submission_id"], "invalid_approval_submission_id")
|
|
approval_event_id = _require_uuid(
|
|
approval_payload["approval_event_id"], "invalid_approval_event_id"
|
|
)
|
|
effect_record_id = _require_uuid(
|
|
approval_payload["effect_record_id"], "invalid_effect_record_id"
|
|
)
|
|
if (
|
|
approval_payload["target_kind"] != "release_gate"
|
|
or approval_payload["target_id"] != gate_id
|
|
or approval_payload["decision"] != "authorize_rollback"
|
|
or approval_payload["reason_code"] != "nas_preview_runtime_rollback_probe"
|
|
):
|
|
raise ProbeError("invalid_approval_binding")
|
|
approval_evidence = _require_evidence_refs(approval_payload["evidence_refs"])
|
|
if approval_evidence != release_evidence:
|
|
raise ProbeError("release_approval_evidence_mismatch")
|
|
|
|
expected_request = _require_exact_dict(
|
|
plan["expected_executor_request"],
|
|
{
|
|
"schema_version",
|
|
"idempotency_key",
|
|
"approval_event_id",
|
|
"rollback_scope",
|
|
"target_kind",
|
|
"target_id",
|
|
"subject_id",
|
|
"rollback_target_id",
|
|
"artifact_record_id",
|
|
"artifact_id",
|
|
"artifact_sha256",
|
|
"artifact_provenance_uri",
|
|
"authorization_evidence_refs",
|
|
},
|
|
"invalid_executor_request_shape",
|
|
)
|
|
expected_binding = {
|
|
"schema_version": EXECUTOR_REQUEST_SCHEMA,
|
|
"idempotency_key": effect_record_id,
|
|
"approval_event_id": approval_event_id,
|
|
"rollback_scope": "runtime",
|
|
"target_kind": "release_gate",
|
|
"target_id": gate_id,
|
|
"subject_id": release_id,
|
|
"rollback_target_id": rollback_artifact["artifact_id"],
|
|
"artifact_record_id": rollback_artifact["artifact_record_id"],
|
|
"artifact_id": rollback_artifact["artifact_id"],
|
|
"artifact_sha256": rollback_artifact["content_sha256"],
|
|
"artifact_provenance_uri": rollback_artifact["provenance_uri"],
|
|
"authorization_evidence_refs": approval_evidence,
|
|
}
|
|
if expected_request != expected_binding:
|
|
raise ProbeError("executor_request_binding_mismatch")
|
|
|
|
executor_manifest = _require_exact_dict(
|
|
plan["executor_manifest"],
|
|
{"file_sha256", "payload"},
|
|
"invalid_executor_manifest_envelope",
|
|
)
|
|
executor_payload = _require_exact_dict(
|
|
executor_manifest["payload"],
|
|
{"schema_version", "target", "artifacts"},
|
|
"invalid_executor_manifest_shape",
|
|
)
|
|
if executor_payload["schema_version"] != EXECUTOR_MANIFEST_SCHEMA:
|
|
raise ProbeError("invalid_executor_manifest_schema")
|
|
if executor_payload["target"] != {
|
|
"compose_project": TARGET_PROJECT,
|
|
"remote_root": TARGET_ROOT,
|
|
"services": TARGET_SERVICES,
|
|
"health_url": TARGET_HEALTH_URL,
|
|
}:
|
|
raise ProbeError("invalid_executor_manifest_target")
|
|
if (
|
|
not isinstance(executor_payload["artifacts"], list)
|
|
or len(executor_payload["artifacts"]) != 1
|
|
):
|
|
raise ProbeError("invalid_executor_manifest_artifacts")
|
|
manifest_artifact = _require_exact_dict(
|
|
executor_payload["artifacts"][0],
|
|
{
|
|
"idempotency_key",
|
|
"approval_event_id",
|
|
"authorization_evidence_refs",
|
|
"artifact_id",
|
|
"artifact_sha256",
|
|
"artifact_record_id",
|
|
"artifact_provenance_uri",
|
|
"target_id",
|
|
"subject_id",
|
|
"rollback_target_id",
|
|
"compose_file",
|
|
"compose_sha256",
|
|
"env_file",
|
|
"env_sha256",
|
|
"resolved_config_sha256",
|
|
"api_image",
|
|
"web_image",
|
|
},
|
|
"invalid_executor_manifest_artifact_shape",
|
|
)
|
|
for field in expected_binding:
|
|
if field in {"schema_version", "rollback_scope", "target_kind"}:
|
|
continue
|
|
manifest_field = (
|
|
"authorization_evidence_refs"
|
|
if field == "authorization_evidence_refs"
|
|
else field
|
|
)
|
|
if manifest_artifact.get(manifest_field) != expected_binding[field]:
|
|
raise ProbeError("executor_manifest_binding_mismatch")
|
|
expected_root = (
|
|
f"{TARGET_ROOT}/.rollback-executor/runtime/{rollback_artifact['artifact_id']}"
|
|
)
|
|
if manifest_artifact["compose_file"] != f"{expected_root}/docker-compose.yml":
|
|
raise ProbeError("invalid_compose_file")
|
|
if manifest_artifact["env_file"] != f"{expected_root}/.env":
|
|
raise ProbeError("invalid_env_file")
|
|
_require_sha256(manifest_artifact["compose_sha256"], "invalid_compose_sha256")
|
|
_require_sha256(manifest_artifact["env_sha256"], "invalid_env_sha256")
|
|
_require_sha256(
|
|
manifest_artifact["resolved_config_sha256"],
|
|
"invalid_resolved_config_sha256",
|
|
)
|
|
_require_image(manifest_artifact["api_image"], "invalid_api_image")
|
|
_require_image(manifest_artifact["web_image"], "invalid_web_image")
|
|
manifest_digest = _require_sha256(
|
|
executor_manifest["file_sha256"], "invalid_executor_manifest_sha256"
|
|
)
|
|
if manifest_digest != hashlib.sha256(_manifest_bytes(executor_payload)).hexdigest():
|
|
raise ProbeError("executor_manifest_sha256_mismatch")
|
|
|
|
if plan["safety"] != {
|
|
"control_plane_required": True,
|
|
"main_preview_self_rollback_forbidden": True,
|
|
"contains_secrets": False,
|
|
"contains_pii": False,
|
|
}:
|
|
raise ProbeError("invalid_safety_contract")
|
|
return plan
|
|
|
|
|
|
def load_plan(path: Path) -> dict[str, Any]:
|
|
try:
|
|
raw = path.read_bytes()
|
|
except OSError as exc:
|
|
raise ProbeError("plan_unreadable") from exc
|
|
if not raw or len(raw) > MAX_RESPONSE_BYTES:
|
|
raise ProbeError("plan_size_rejected")
|
|
try:
|
|
value = json.loads(raw)
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ProbeError("plan_json_invalid") from exc
|
|
return validate_plan(value)
|
|
|
|
|
|
def _normalize_base_url(
|
|
value: str, *, label: str, require_secure_transport: bool
|
|
) -> tuple[str, str]:
|
|
parsed = urlsplit(value)
|
|
hostname = (parsed.hostname or "").lower()
|
|
local_http = parsed.scheme == "http" and hostname in {
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"::1",
|
|
}
|
|
if parsed.scheme not in {"http", "https"}:
|
|
raise ProbeError(f"invalid_{label}_url")
|
|
if require_secure_transport and parsed.scheme != "https" and not local_http:
|
|
raise ProbeError(f"{label}_must_use_https")
|
|
if (
|
|
not parsed.netloc
|
|
or parsed.username
|
|
or parsed.password
|
|
or parsed.query
|
|
or parsed.fragment
|
|
):
|
|
raise ProbeError(f"invalid_{label}_url")
|
|
normalized = value.rstrip("/")
|
|
origin = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
|
|
return normalized, origin
|
|
|
|
|
|
class ApiClient:
|
|
def __init__(self, base_url: str, timeout: float) -> None:
|
|
self.base_url = base_url.rstrip("/")
|
|
self.timeout = timeout
|
|
self._opener = urllib.request.build_opener(
|
|
urllib.request.ProxyHandler({}),
|
|
_NoRedirect(),
|
|
urllib.request.HTTPCookieProcessor(CookieJar()),
|
|
)
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
payload: dict[str, Any] | None = None,
|
|
*,
|
|
headers: dict[str, str] | None = None,
|
|
expected: set[int] | None = None,
|
|
) -> ApiResponse:
|
|
data = None
|
|
request_headers = {"Accept": "application/json", **(headers or {})}
|
|
if payload is not None:
|
|
data = _canonical_bytes(payload)
|
|
request_headers["Content-Type"] = "application/json"
|
|
request = urllib.request.Request(
|
|
f"{self.base_url}{path}",
|
|
data=data,
|
|
headers=request_headers,
|
|
method=method,
|
|
)
|
|
try:
|
|
with self._opener.open(request, timeout=self.timeout) as response:
|
|
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
|
status = response.status
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
exc.read(MAX_RESPONSE_BYTES + 1)
|
|
finally:
|
|
exc.close()
|
|
raise ProbeError(f"http_{exc.code}_{method.lower()}") from None
|
|
except (urllib.error.URLError, TimeoutError, OSError):
|
|
raise ProbeError(f"transport_failed_{method.lower()}") from None
|
|
if status not in (expected or {200}):
|
|
raise ProbeError(f"unexpected_http_{status}_{method.lower()}")
|
|
if len(raw) > MAX_RESPONSE_BYTES:
|
|
raise ProbeError("response_size_rejected")
|
|
try:
|
|
body = json.loads(raw) if raw else {}
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
raise ProbeError("response_json_invalid") from None
|
|
return ApiResponse(status=status, body=body)
|
|
|
|
|
|
def _require_response_dict(value: Any, code: str) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise ProbeError(code)
|
|
return value
|
|
|
|
|
|
def _single(items: Any, predicate: Any, code: str) -> dict[str, Any]:
|
|
if not isinstance(items, list):
|
|
raise ProbeError(code)
|
|
matches = [item for item in items if isinstance(item, dict) and predicate(item)]
|
|
if len(matches) != 1:
|
|
raise ProbeError(code)
|
|
return matches[0]
|
|
|
|
|
|
def execute_plan(
|
|
*,
|
|
plan: dict[str, Any],
|
|
control_plane_base_url: str,
|
|
preview_base_url: str,
|
|
internal_token: str,
|
|
admin_email: str,
|
|
timeout: float,
|
|
) -> dict[str, Any]:
|
|
plan = validate_plan(plan)
|
|
control_url, control_origin = _normalize_base_url(
|
|
control_plane_base_url,
|
|
label="control_plane",
|
|
require_secure_transport=True,
|
|
)
|
|
_, preview_origin = _normalize_base_url(
|
|
preview_base_url,
|
|
label="preview",
|
|
require_secure_transport=False,
|
|
)
|
|
if control_origin == preview_origin:
|
|
raise ProbeError("main_preview_self_rollback_forbidden")
|
|
if not isinstance(internal_token, str) or len(internal_token) < 32:
|
|
raise ProbeError("internal_token_unavailable")
|
|
if (
|
|
not isinstance(admin_email, str)
|
|
or "@" not in admin_email
|
|
or len(admin_email) > 320
|
|
):
|
|
raise ProbeError("admin_identity_unavailable")
|
|
if timeout <= 0 or timeout > 300:
|
|
raise ProbeError("invalid_timeout")
|
|
|
|
client = ApiClient(control_url, timeout)
|
|
internal_headers = {INTERNAL_TOKEN_HEADER: internal_token}
|
|
release = plan["release_gate"]
|
|
release_payload = release["payload"]
|
|
created = client.request(
|
|
"POST",
|
|
release["path"],
|
|
release_payload,
|
|
headers=internal_headers,
|
|
expected={201},
|
|
).body
|
|
replayed = client.request(
|
|
"POST",
|
|
release["path"],
|
|
release_payload,
|
|
headers=internal_headers,
|
|
expected={201},
|
|
).body
|
|
for response in (created, replayed):
|
|
response = _require_response_dict(response, "release_response_invalid")
|
|
if (
|
|
response.get("gate_id") != release_payload["gate_id"]
|
|
or response.get("qualified") is not True
|
|
or response.get("state") != "pending_human_approval"
|
|
or response.get("promotion_executed") is not False
|
|
):
|
|
raise ProbeError("release_response_binding_mismatch")
|
|
if replayed.get("idempotent_replay") is not True:
|
|
raise ProbeError("release_gate_not_idempotent")
|
|
|
|
client.request(
|
|
"POST",
|
|
DEV_LOGIN_PATH,
|
|
{
|
|
"email": admin_email,
|
|
"role": "admin",
|
|
"display_name": "G8 Rollback Control Plane Probe",
|
|
"cohort_ids": [],
|
|
},
|
|
headers={"Origin": control_origin},
|
|
expected={200},
|
|
)
|
|
approval = plan["approval"]
|
|
approval_payload = approval["payload"]
|
|
approved = client.request(
|
|
"POST", approval["path"], approval_payload, expected={201}
|
|
).body
|
|
approval_replay = client.request(
|
|
"POST", approval["path"], approval_payload, expected={201}
|
|
).body
|
|
for response in (approved, approval_replay):
|
|
response = _require_response_dict(response, "approval_response_invalid")
|
|
if (
|
|
response.get("approval_event_id") != approval_payload["approval_event_id"]
|
|
or response.get("effect_record_id") != approval_payload["effect_record_id"]
|
|
or response.get("target_kind") != "release_gate"
|
|
or response.get("target_id") != release_payload["gate_id"]
|
|
or response.get("decision") != "authorize_rollback"
|
|
):
|
|
raise ProbeError("approval_response_binding_mismatch")
|
|
if approval_replay.get("idempotent_replay") is not True:
|
|
raise ProbeError("approval_not_idempotent")
|
|
|
|
view = client.request(
|
|
"GET", READ_MODEL_PATH, headers=internal_headers, expected={200}
|
|
).body
|
|
view = _require_response_dict(view, "read_model_invalid")
|
|
for flag in (
|
|
"silent_auto_promotion_allowed",
|
|
"raw_transcript_included",
|
|
"pii_included",
|
|
"clinical_claim_allowed",
|
|
):
|
|
if view.get(flag) is not False:
|
|
raise ProbeError("read_model_safety_boundary_failed")
|
|
gate_id = release_payload["gate_id"]
|
|
release_gate = _single(
|
|
view.get("release_gates"),
|
|
lambda item: item.get("gate_id") == gate_id,
|
|
"release_gate_read_model_mismatch",
|
|
)
|
|
if (
|
|
release_gate.get("release_id") != release_payload["manifest"]["release_id"]
|
|
or release_gate.get("qualified") is not True
|
|
or release_gate.get("state") != "pending_human_approval"
|
|
):
|
|
raise ProbeError("release_gate_read_model_mismatch")
|
|
expected_request = plan["expected_executor_request"]
|
|
artifact = _single(
|
|
view.get("gate_artifacts"),
|
|
lambda item: (
|
|
item.get("artifact_record_id") == expected_request["artifact_record_id"]
|
|
),
|
|
"rollback_artifact_read_model_mismatch",
|
|
)
|
|
if (
|
|
artifact.get("owner_kind") != "release_gate"
|
|
or artifact.get("owner_id") != gate_id
|
|
or artifact.get("artifact_kind") != "rollback"
|
|
or artifact.get("artifact_id") != expected_request["artifact_id"]
|
|
or artifact.get("content_sha256") != expected_request["artifact_sha256"]
|
|
or artifact.get("provenance_uri") != expected_request["artifact_provenance_uri"]
|
|
):
|
|
raise ProbeError("rollback_artifact_read_model_mismatch")
|
|
approval_event = _single(
|
|
view.get("approvals"),
|
|
lambda item: (
|
|
item.get("approval_event_id") == approval_payload["approval_event_id"]
|
|
),
|
|
"approval_read_model_mismatch",
|
|
)
|
|
if (
|
|
approval_event.get("target_kind") != "release_gate"
|
|
or approval_event.get("target_id") != gate_id
|
|
or approval_event.get("decision") != "authorize_rollback"
|
|
or approval_event.get("evidence_refs") != approval_payload["evidence_refs"]
|
|
):
|
|
raise ProbeError("approval_read_model_mismatch")
|
|
lifecycle = _single(
|
|
view.get("lifecycle_events"),
|
|
lambda item: (
|
|
item.get("lifecycle_event_id") == approval_payload["effect_record_id"]
|
|
),
|
|
"rollback_lifecycle_read_model_mismatch",
|
|
)
|
|
executor_refs = lifecycle.get("executor_evidence_refs")
|
|
lifecycle_refs = lifecycle.get("evidence_refs")
|
|
receipt_id = lifecycle.get("executor_receipt_id")
|
|
if (
|
|
lifecycle.get("target_kind") != "release_gate"
|
|
or lifecycle.get("target_id") != gate_id
|
|
or lifecycle.get("event_type") != "rollback"
|
|
or lifecycle.get("event_status") != "executed"
|
|
or lifecycle.get("approval_event_id") != approval_payload["approval_event_id"]
|
|
or lifecycle.get("artifact_record_id") != expected_request["artifact_record_id"]
|
|
or not isinstance(receipt_id, str)
|
|
or not receipt_id
|
|
or not isinstance(executor_refs, list)
|
|
or not executor_refs
|
|
or not isinstance(lifecycle_refs, list)
|
|
or not set(approval_payload["evidence_refs"]).issubset(lifecycle_refs)
|
|
or not set(executor_refs).issubset(lifecycle_refs)
|
|
):
|
|
raise ProbeError("rollback_executed_receipt_binding_mismatch")
|
|
|
|
result = {
|
|
"schema_version": RESULT_SCHEMA,
|
|
"probe_id": plan["probe_id"],
|
|
"plan_sha256": plan["plan_sha256"],
|
|
"release_gate_id": gate_id,
|
|
"approval_event_id": approval_payload["approval_event_id"],
|
|
"lifecycle_event_id": approval_payload["effect_record_id"],
|
|
"lifecycle_status": "executed",
|
|
"artifact_record_id": expected_request["artifact_record_id"],
|
|
"artifact_id": expected_request["artifact_id"],
|
|
"artifact_sha256": expected_request["artifact_sha256"],
|
|
"executor_receipt_id": receipt_id,
|
|
"executor_evidence_refs": executor_refs,
|
|
"release_gate_idempotent_replay": True,
|
|
"approval_idempotent_replay": True,
|
|
"binding_verified": True,
|
|
"control_plane_separation_verified": True,
|
|
"contains_secrets": False,
|
|
"contains_pii": False,
|
|
}
|
|
if set(result) != RESULT_FIELDS:
|
|
raise ProbeError("result_shape_invalid")
|
|
return result
|
|
|
|
|
|
def _write_json(path: Path, value: Any) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__,
|
|
epilog=(
|
|
"SAFETY: the main preview API must not self-authorize rollback. "
|
|
"Execute only against a separately deployed control-plane API whose "
|
|
"rollback executor and immutable d31b0d manifest are provisioned."
|
|
),
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
prepare = subparsers.add_parser(
|
|
"prepare", help="emit deterministic request and executor-manifest bindings"
|
|
)
|
|
prepare.add_argument("--suffix", required=True)
|
|
prepare.add_argument("--release-id")
|
|
prepare.add_argument("--artifact-id", required=True)
|
|
prepare.add_argument("--artifact-sha256", required=True)
|
|
prepare.add_argument("--artifact-provenance-uri", required=True)
|
|
prepare.add_argument("--evidence-ref", action="append", required=True)
|
|
prepare.add_argument("--compose-sha256", required=True)
|
|
prepare.add_argument("--env-sha256", required=True)
|
|
prepare.add_argument("--resolved-config-sha256", required=True)
|
|
prepare.add_argument("--api-image", required=True)
|
|
prepare.add_argument("--web-image", required=True)
|
|
prepare.add_argument("--output", type=Path)
|
|
prepare.add_argument("--executor-manifest-output", type=Path)
|
|
|
|
execute = subparsers.add_parser(
|
|
"execute",
|
|
help="exercise a separate control-plane API and verify executed receipt binding",
|
|
)
|
|
execute.add_argument("--plan", type=Path, required=True)
|
|
execute.add_argument("--control-plane-base-url", required=True)
|
|
execute.add_argument("--preview-base-url", required=True)
|
|
execute.add_argument(
|
|
"--internal-token-env",
|
|
default="VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN",
|
|
)
|
|
execute.add_argument(
|
|
"--admin-email-env", default="VIGNETTE_G8_ROLLBACK_PROBE_ADMIN_EMAIL"
|
|
)
|
|
execute.add_argument("--timeout", type=float, default=30.0)
|
|
execute.add_argument("--output", type=Path)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = _build_parser().parse_args(argv)
|
|
try:
|
|
if args.command == "prepare":
|
|
plan = build_plan(
|
|
suffix=args.suffix,
|
|
release_id=args.release_id,
|
|
artifact_id=args.artifact_id,
|
|
artifact_sha256=args.artifact_sha256,
|
|
artifact_provenance_uri=args.artifact_provenance_uri,
|
|
evidence_refs=args.evidence_ref,
|
|
compose_sha256=args.compose_sha256,
|
|
env_sha256=args.env_sha256,
|
|
resolved_config_sha256=args.resolved_config_sha256,
|
|
api_image=args.api_image,
|
|
web_image=args.web_image,
|
|
)
|
|
if args.output:
|
|
_write_json(args.output, plan)
|
|
if args.executor_manifest_output:
|
|
args.executor_manifest_output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.executor_manifest_output.write_bytes(
|
|
_manifest_bytes(plan["executor_manifest"]["payload"])
|
|
)
|
|
result = plan
|
|
else:
|
|
plan = load_plan(args.plan)
|
|
result = execute_plan(
|
|
plan=plan,
|
|
control_plane_base_url=args.control_plane_base_url,
|
|
preview_base_url=args.preview_base_url,
|
|
internal_token=os.environ.get(args.internal_token_env, ""),
|
|
admin_email=os.environ.get(args.admin_email_env, ""),
|
|
timeout=args.timeout,
|
|
)
|
|
if args.output:
|
|
_write_json(args.output, result)
|
|
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
|
|
except ProbeError as exc:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"schema_version": "vignette.g8-rollback-control-plane-probe-error.v1",
|
|
"ok": False,
|
|
"error": str(exc),
|
|
},
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|