diff --git a/.github/workflows/security-quality-gate.yml b/.github/workflows/security-quality-gate.yml index 10d9cc83..2aa85f0c 100644 --- a/.github/workflows/security-quality-gate.yml +++ b/.github/workflows/security-quality-gate.yml @@ -77,6 +77,10 @@ jobs: yuqei-infra/deploy \ yuqei-infra/backup \ yuqei-infra/observability \ + yuqei-infra/scripts/build-infrastructure-image-bundle.py \ + yuqei-infra/scripts/build-release-candidate-manifest.py \ + yuqei-infra/scripts/build-release-worktree-ownership.py \ + yuqei-infra/scripts/build-third-party-materials.py \ yuqei-infra/scripts/run-ai-provider-acceptance.py \ -f json \ -o security-artifacts/bandit-project.json diff --git a/yuqei-infra/compose/.env.production.example b/yuqei-infra/compose/.env.production.example index e339046a..e1ea808d 100644 --- a/yuqei-infra/compose/.env.production.example +++ b/yuqei-infra/compose/.env.production.example @@ -2,6 +2,9 @@ COMPOSE_PROJECT_NAME=yuqei-v2-production RELEASE_ID=CHANGE_ME_IMMUTABLE_RC_TAG +YUQEI_RELEASE_ENVIRONMENT_ID=CHANGE_ME_STABLE_ENVIRONMENT_ID +YUQEI_RELEASE_ENVIRONMENT_CLASS=production +YUQEI_RELEASE_HOST_ID=CHANGE_ME_STABLE_HOST_ID CONTRACT_API_IMAGE=registry.example.com/yuqei/contract-api@sha256:CHANGE_ME_64_HEX_DIGEST CONTRACT_WEB_IMAGE=registry.example.com/yuqei/contract-web@sha256:CHANGE_ME_64_HEX_DIGEST AI_PLATFORM_API_IMAGE=registry.example.com/yuqei/ai-platform-api@sha256:CHANGE_ME_64_HEX_DIGEST diff --git a/yuqei-infra/deploy/plan-first-release-snapshot-restore.py b/yuqei-infra/deploy/plan-first-release-snapshot-restore.py index c5a8f604..588cdb61 100644 --- a/yuqei-infra/deploy/plan-first-release-snapshot-restore.py +++ b/yuqei-infra/deploy/plan-first-release-snapshot-restore.py @@ -121,6 +121,25 @@ def canonical_sha256(value: object) -> str: return hashlib.sha256(canonical_json_bytes(value)).hexdigest() +def runtime_host_identity_sha256( + host_id: object, + docker_daemon_identity: Mapping[str, object], +) -> str: + """Derive the recovery host identity from stable local Docker/host fields.""" + + normalized_host_id = _identifier(host_id, label="host_id") + required = ("docker_host", "daemon_id", "daemon_name", "docker_root_dir") + values: dict[str, str] = {} + for field in required: + value = docker_daemon_identity.get(field) + if not isinstance(value, str) or not value.strip(): + raise SnapshotRestorePlanError( + f"Docker daemon identity field is missing for host binding: {field}" + ) + values[field] = value + return canonical_sha256({"host_id": normalized_host_id, **values}) + + def file_sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: @@ -1097,6 +1116,7 @@ def validate_evidence( "environment_class", "host_id", "host_identity_sha256", + "docker_daemon_identity_sha256", "compose_project_name", }, label="recovery target", @@ -1108,6 +1128,10 @@ def validate_evidence( if target.get("compose_project_name") != package_binding.get("compose_project_name"): raise SnapshotRestorePlanError("recovery target Compose project differs from the package") _sha(target.get("host_identity_sha256"), label="host identity SHA-256") + _sha( + target.get("docker_daemon_identity_sha256"), + label="Docker daemon identity SHA-256", + ) consistency_set_id = _identifier( evidence.get("consistency_set_id"), label="consistency_set_id" ) diff --git a/yuqei-infra/deploy/plan-rollback-rehearsal.py b/yuqei-infra/deploy/plan-rollback-rehearsal.py index e0660603..478ac4fd 100644 --- a/yuqei-infra/deploy/plan-rollback-rehearsal.py +++ b/yuqei-infra/deploy/plan-rollback-rehearsal.py @@ -132,6 +132,8 @@ def _exec_verified_module(name: str, path: Path, raw: bytes) -> Any: def _load_release_package_module( verified_sources: Mapping[str, bytes] | None = None, + *, + verified_repo_root: Path = REPO_ROOT, ): name = "yuqei_rollback_rehearsal_release_package" if verified_sources is not None: @@ -178,7 +180,7 @@ def _load_release_package_module( ): loaded[relative] = _exec_verified_module( module_names[relative], - REPO_ROOT / relative, + verified_repo_root / relative, verified_sources[relative], ) release_package = loaded["yuqei-infra/deploy/release-package.py"] @@ -212,7 +214,7 @@ def _load_release_package_module( "yuqei-infra/release-trust/third-party-materials-baseline.json" ) baseline_raw, baseline_sha = _read_secure_bytes( - REPO_ROOT / baseline_relative, + verified_repo_root / baseline_relative, label="verified third-party materials trust baseline", max_bytes=MAX_JSON_BYTES, ) @@ -1710,13 +1712,27 @@ def plan_from_files( current_package_sha256=current_package_sha256, repo_root=repo_root, ) - release_package = _load_release_package_module(verified_tool_sources) + predecessor_tool_sources, predecessor_bootstrap_tool_hashes = ( + _bootstrap_verified_tool_sources( + current_package_dir=predecessor_package_dir, + current_package_sha256=predecessor_package_sha256, + repo_root=predecessor_repo_root, + ) + ) + release_package = _load_release_package_module( + verified_tool_sources, + verified_repo_root=repo_root, + ) + predecessor_release_package = _load_release_package_module( + predecessor_tool_sources, + verified_repo_root=predecessor_repo_root, + ) current = release_package.validate_release_package( current_package_dir, expected_package_sha256=current_package_sha256, repo_root=repo_root, ) - predecessor = release_package.validate_release_package( + predecessor = predecessor_release_package.validate_release_package( predecessor_package_dir, expected_package_sha256=predecessor_package_sha256, repo_root=predecessor_repo_root, @@ -1777,6 +1793,14 @@ def plan_from_files( raise RehearsalProofError( "Rollback tool inputs changed after the pre-import package bootstrap." ) + predecessor_validated_tool_hashes = _validated_tool_input_hashes( + current_package=predecessor, + repo_root=predecessor_repo_root, + ) + if predecessor_validated_tool_hashes != predecessor_bootstrap_tool_hashes: + raise RehearsalProofError( + "Predecessor rollback tool inputs changed after the pre-import package bootstrap." + ) runtime_binding = _build_runtime_binding( current_package=current, predecessor_package=predecessor, diff --git a/yuqei-infra/deploy/preflight-production.py b/yuqei-infra/deploy/preflight-production.py index e3330e1b..8b8aa303 100644 --- a/yuqei-infra/deploy/preflight-production.py +++ b/yuqei-infra/deploy/preflight-production.py @@ -25,6 +25,7 @@ DEFAULT_ARTIFACT_ROOT = REPO_ROOT / "project-docs" / "qa-artifacts" / "v2-produc IMMUTABLE_IMAGE_PATTERN = re.compile(r"^[^\s]+@sha256:[0-9a-fA-F]{64}$") IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") RELEASE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +RUNTIME_TARGET_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") IMAGE_DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") PLACEHOLDER_MARKERS = ("change_me", "local-only", "example.com", "your-api-key", "replace_with") @@ -130,6 +131,9 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False) required = ( "COMPOSE_PROJECT_NAME", "RELEASE_ID", + "YUQEI_RELEASE_ENVIRONMENT_ID", + "YUQEI_RELEASE_ENVIRONMENT_CLASS", + "YUQEI_RELEASE_HOST_ID", "CONTRACT_API_IMAGE", "CONTRACT_WEB_IMAGE", "AI_PLATFORM_API_IMAGE", @@ -198,6 +202,34 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False) if values.get(key, "").strip() != expected ] checks.append(_result("production_modes", not invalid_exact, "Production modes are strict." if not invalid_exact else f"Invalid production values: {', '.join(invalid_exact)}.")) + target_ids = { + "YUQEI_RELEASE_ENVIRONMENT_ID": values.get( + "YUQEI_RELEASE_ENVIRONMENT_ID", "" + ).strip(), + "YUQEI_RELEASE_HOST_ID": values.get("YUQEI_RELEASE_HOST_ID", "").strip(), + } + invalid_target_ids = [ + key + for key, value in target_ids.items() + if not RUNTIME_TARGET_ID_PATTERN.fullmatch(value) + ] + environment_class = values.get("YUQEI_RELEASE_ENVIRONMENT_CLASS", "").strip() + target_ok = not invalid_target_ids and environment_class in { + "isolated_uat", + "production", + } + checks.append( + _result( + "release_runtime_target", + target_ok, + ( + "Release environment and host identity are explicit." + if target_ok + else "Release environment_id/host_id must be stable identifiers and " + "environment_class must be isolated_uat or production." + ), + ) + ) checks.append(_validate_identity_email_delivery(values)) checks.append(_validate_ai_timeout_budget(values)) checks.append(_validate_capacity_thresholds(values)) diff --git a/yuqei-infra/deploy/release-package.py b/yuqei-infra/deploy/release-package.py index c0774fa9..46d2aa12 100644 --- a/yuqei-infra/deploy/release-package.py +++ b/yuqei-infra/deploy/release-package.py @@ -23,6 +23,7 @@ import re import stat import subprocess import sys +import time from contextlib import contextmanager from dataclasses import dataclass from datetime import UTC, datetime @@ -57,8 +58,8 @@ RECEIPT_SCHEMA = "yuqei.release-package-receipt.v3" PENDING_SCHEMA = "yuqei.release-package-pending.v3" POST_APPLY_SCHEMA = "yuqei.release-post-apply-check.v2" PREFLIGHT_BINDING_SCHEMA = "yuqei.production-preflight-binding.v2" -INFRASTRUCTURE_SOURCE_SET_SCHEMA = "yuqei.infrastructure-image-source-set.v1" -INFRASTRUCTURE_BUNDLE_SCHEMA = "yuqei.infrastructure-image-bundle.v1" +INFRASTRUCTURE_SOURCE_SET_SCHEMA = "yuqei.infrastructure-image-source-set.v2" +INFRASTRUCTURE_BUNDLE_SCHEMA = "yuqei.infrastructure-image-bundle.v2" THIRD_PARTY_INPUT_SCHEMA = "yuqei.third-party-materials-input.v1" THIRD_PARTY_MANIFEST_SCHEMA = "yuqei.third-party-materials.v1" THIRD_PARTY_VERIFICATION_SCHEMA = "yuqei.third-party-materials-verification.v1" @@ -153,6 +154,7 @@ INSTALL_SERVICES = ( "minio", "contract-api-migrate", "ai-platform-api-migrate", + "ai-platform-prompt-assets-import", *RUNTIME_SERVICES, ) V1_REQUIRED_ARTIFACTS = ( @@ -185,9 +187,22 @@ RELEASE_IMPORT_ROLE_ENV_SERVICES = ( ( "contract-api", "CONTRACT_API_IMAGE", - ("contract-api", "contract-review-worker", "contract-playbook-governance-worker"), + ( + "contract-api", + "contract-api-migrate", + "contract-review-worker", + "contract-playbook-governance-worker", + ), + ), + ( + "ai-platform-api", + "AI_PLATFORM_API_IMAGE", + ( + "ai-platform-api", + "ai-platform-api-migrate", + "ai-platform-prompt-assets-import", + ), ), - ("ai-platform-api", "AI_PLATFORM_API_IMAGE", ("ai-platform-api",)), ("ai-worker", "AI_WORKER_IMAGE", ("worker",)), ("postgres-runtime", "POSTGRES_IMAGE", ("postgres",)), ("postgres-client", "POSTGRES_CLIENT_IMAGE", ("postgres-init-databases",)), @@ -209,8 +224,12 @@ ONE_SHOT_INSTALL_SERVICES = ( "postgres-init-databases", "contract-api-migrate", "ai-platform-api-migrate", + "ai-platform-prompt-assets-import", ) +POST_APPLY_WAIT_TIMEOUT_SECONDS = 300 +POST_APPLY_POLL_INTERVAL_SECONDS = 2 + SHA256_RE = re.compile(r"^[0-9a-f]{64}$") COMMIT_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") RELEASE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") @@ -1309,6 +1328,8 @@ def _validate_third_party_materials( candidate_tag: str, source_manifest_sha256: str, release_import_plan: tuple[dict[str, Any], ...], + installable_manifest: Mapping[str, Any], + infrastructure_bundle: Mapping[str, Any], ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], str]: materials_input = _read_json(input_path, label="third-party materials input") if materials_input.get("schema_version") != THIRD_PARTY_INPUT_SCHEMA: @@ -1365,14 +1386,90 @@ def _validate_third_party_materials( ) for item in release_import_plan } + + def report_fingerprint(raw: object, *, label: str) -> tuple[str, int]: + if not isinstance(raw, Mapping): + raise ReleasePackageError(f"{label} record is missing.") + digest = raw.get("sha256") + size = raw.get("size_bytes") + if not isinstance(digest, str) or not SHA256_RE.fullmatch(digest): + raise ReleasePackageError(f"{label} record has an invalid SHA-256.") + if not isinstance(size, int) or isinstance(size, bool) or size <= 0: + raise ReleasePackageError(f"{label} record has an invalid size.") + return digest, size + + formal_reports: dict[str, dict[str, tuple[str, int]]] = {} + formal_image_groups = ( + ("application", installable_manifest.get("images")), + ("infrastructure", infrastructure_bundle.get("infrastructure_images")), + ) + for image_class, raw_group in formal_image_groups: + if not isinstance(raw_group, list): + raise ReleasePackageError( + f"Formal {image_class} image security-report inventory is missing." + ) + for raw_image in raw_group: + if not isinstance(raw_image, Mapping): + raise ReleasePackageError( + f"Formal {image_class} image security-report entry is invalid." + ) + role = raw_image.get("role") + if not isinstance(role, str) or role not in expected_images: + raise ReleasePackageError( + f"Formal {image_class} image security-report role is invalid." + ) + if role in formal_reports: + raise ReleasePackageError( + f"Formal image security-report inventory repeats role: {role}" + ) + expected_manifest_digest, expected_config_digest = expected_images[role] + if ( + raw_image.get("manifest_digest") != expected_manifest_digest + or raw_image.get("config_digest") != expected_config_digest + ): + raise ReleasePackageError( + f"Formal image security reports bind a different image: {role}" + ) + formal_reports[role] = { + "sbom": report_fingerprint( + raw_image.get("sbom"), label=f"formal {role} SBOM" + ), + "trivy_license": report_fingerprint( + raw_image.get("license_scan"), + label=f"formal {role} Trivy license report", + ), + } + if set(formal_reports) != set(expected_images): + raise ReleasePackageError( + "Formal image security reports do not cover exactly all eleven release images." + ) + observed_images: dict[str, tuple[object, object]] = {} for item in raw_images: if not isinstance(item, Mapping) or not isinstance(item.get("role"), str): raise ReleasePackageError("Third-party image identity is invalid.") - observed_images[item["role"]] = ( + role = item["role"] + if role in observed_images: + raise ReleasePackageError(f"Third-party image role is repeated: {role}") + observed_images[role] = ( item.get("manifest_digest"), item.get("config_digest"), ) + expected_reports = formal_reports.get(role) + if expected_reports is None: + raise ReleasePackageError( + f"Third-party image role has no formal security reports: {role}" + ) + for report_kind in ("sbom", "trivy_license"): + observed_report = report_fingerprint( + item.get(report_kind), + label=f"third-party {role} {report_kind}", + ) + if observed_report != expected_reports[report_kind]: + raise ReleasePackageError( + "Third-party materials use a different raw image report than the " + f"formal build gate: {role} {report_kind}." + ) if observed_images != expected_images: raise ReleasePackageError("Third-party image identities differ from the release plan.") return materials_input, materials_manifest, included_receipt, profile @@ -1788,6 +1885,8 @@ def validate_release_package( candidate_tag=candidate_tag, source_manifest_sha256=release_sha, release_import_plan=docker_import_plan, + installable_manifest=installable_manifest, + infrastructure_bundle=infrastructure_bundle, ) backup_proof: dict[str, Any] | None = None @@ -2360,107 +2459,160 @@ def _builtin_post_apply_checker( up_index = command_argv.index("up") compose_prefix = command_argv[:up_index] ps_command = [*compose_prefix, "ps", "--all", "--format", "json"] - completed = _fixed_subprocess_runner( - ps_command, - check=False, - capture_output=True, - text=True, - shell=False, - cwd=str(Path(command_argv[command_argv.index("-f") + 1]).parent), - env=dict(runtime_environment), - timeout=60, - ) - if int(completed.returncode) != 0: - raise ReleasePackageError("Built-in post-apply Compose inventory check failed.") - parsed = _parse_json_value(completed.stdout, label="Compose post-apply inventory") - if isinstance(parsed, dict): - rows = [parsed] - elif isinstance(parsed, list) and all(isinstance(item, dict) for item in parsed): - rows = parsed - else: - raise ReleasePackageError("Compose post-apply inventory has an invalid shape.") expected_services = list(plan["deployed_service_set"]) - by_service: dict[str, Mapping[str, Any]] = {} - for row in rows: - service = row.get("Service") - if not isinstance(service, str) or service in by_service: - raise ReleasePackageError("Compose post-apply inventory repeats or omits a service.") - by_service[service] = row - if set(by_service) != set(expected_services): - raise ReleasePackageError("Compose post-apply service inventory differs from the plan.") - service_image_ids = { - service: item["expected_loaded_image_id"] - for item in plan["docker_import_plan"] - for service in item["services"] - if service in expected_services - } + service_image_ids: dict[str, str] = {} + for item in plan["docker_import_plan"]: + for service in item["services"]: + if service not in expected_services: + continue + if service in service_image_ids: + raise ReleasePackageError( + f"Post-apply image plan repeats service binding: {service}." + ) + service_image_ids[service] = item["expected_loaded_image_id"] + if set(service_image_ids) != set(expected_services): + raise ReleasePackageError("Post-apply image plan does not cover every deployed service.") + + deadline = time.monotonic() + POST_APPLY_WAIT_TIMEOUT_SECONDS records: list[dict[str, Any]] = [] release_binding_verified = False - for service in expected_services: - row = by_service[service] - container_id = row.get("ID") - if not isinstance(container_id, str) or not container_id: - raise ReleasePackageError(f"Post-apply container ID is missing for {service}.") - inspect_command = [ - str(DOCKER_CLI), - "--host", - LOCAL_DOCKER_HOST, - "inspect", - "--format", - "{{json .}}", - container_id, - ] - inspected = _fixed_subprocess_runner( - inspect_command, + while True: + completed = _fixed_subprocess_runner( + ps_command, check=False, capture_output=True, text=True, shell=False, + cwd=str(Path(command_argv[command_argv.index("-f") + 1]).parent), env=dict(runtime_environment), - timeout=30, + timeout=60, ) - if int(inspected.returncode) != 0: - raise ReleasePackageError(f"Cannot inspect post-apply container {service}.") - detail = _parse_json_text(inspected.stdout, label=f"{service} container inspect") - config = detail.get("Config") - state = detail.get("State") - labels = config.get("Labels") if isinstance(config, Mapping) else None - if ( - not isinstance(config, Mapping) - or not isinstance(state, Mapping) - or not isinstance(labels, Mapping) - or labels.get("com.docker.compose.project") != plan["compose_project_name"] - or labels.get("com.docker.compose.service") != service - or detail.get("Image") != service_image_ids.get(service) - ): - raise ReleasePackageError(f"Post-apply project or image binding failed for {service}.") - status = state.get("Status") - health_payload = state.get("Health") - health = health_payload.get("Status") if isinstance(health_payload, Mapping) else None - exit_code = state.get("ExitCode") - if service in ONE_SHOT_INSTALL_SERVICES: - if status != "exited" or exit_code != 0: - raise ReleasePackageError(f"Post-apply migration/init failed for {service}.") - elif status != "running" or health != "healthy": - raise ReleasePackageError(f"Post-apply service is not healthy: {service}.") - env_items = config.get("Env") - if service == "ai-platform-api": + if int(completed.returncode) != 0: + raise ReleasePackageError("Built-in post-apply Compose inventory check failed.") + parsed = _parse_json_value(completed.stdout, label="Compose post-apply inventory") + if isinstance(parsed, dict): + rows = [parsed] + elif isinstance(parsed, list) and all(isinstance(item, dict) for item in parsed): + rows = parsed + else: + raise ReleasePackageError("Compose post-apply inventory has an invalid shape.") + by_service: dict[str, Mapping[str, Any]] = {} + for row in rows: + service = row.get("Service") + if not isinstance(service, str) or service in by_service: + raise ReleasePackageError( + "Compose post-apply inventory repeats or has an invalid service." + ) + by_service[service] = row + unexpected = set(by_service).difference(expected_services) + if unexpected: + raise ReleasePackageError("Compose post-apply service inventory exceeds the plan.") + + pending_services = set(expected_services).difference(by_service) + current_records: list[dict[str, Any]] = [] + current_release_binding_verified = False + for service in expected_services: + row = by_service.get(service) + if row is None: + continue + container_id = row.get("ID") + if not isinstance(container_id, str) or not container_id: + raise ReleasePackageError(f"Post-apply container ID is missing for {service}.") + inspect_command = [ + str(DOCKER_CLI), + "--host", + LOCAL_DOCKER_HOST, + "inspect", + "--format", + "{{json .}}", + container_id, + ] + inspected = _fixed_subprocess_runner( + inspect_command, + check=False, + capture_output=True, + text=True, + shell=False, + env=dict(runtime_environment), + timeout=30, + ) + if int(inspected.returncode) != 0: + raise ReleasePackageError(f"Cannot inspect post-apply container {service}.") + detail = _parse_json_text(inspected.stdout, label=f"{service} container inspect") + config = detail.get("Config") + state = detail.get("State") + labels = config.get("Labels") if isinstance(config, Mapping) else None if ( - not isinstance(env_items, list) - or f"AI_PLATFORM_RELEASE_ID={plan['target_release_id']}" not in env_items + not isinstance(config, Mapping) + or not isinstance(state, Mapping) + or not isinstance(labels, Mapping) + or labels.get("com.docker.compose.project") != plan["compose_project_name"] + or labels.get("com.docker.compose.service") != service + or detail.get("Image") != service_image_ids[service] ): - raise ReleasePackageError("AI runtime release identity is not bound.") - release_binding_verified = True - records.append( - { - "service": service, - "container_id_sha256": hashlib.sha256(container_id.encode("utf-8")).hexdigest(), - "status": status, - "health": health, - "exit_code": exit_code, - "image_id": detail.get("Image"), - } - ) + raise ReleasePackageError( + f"Post-apply project or image binding failed for {service}." + ) + status = state.get("Status") + health_payload = state.get("Health") + health = ( + health_payload.get("Status") + if isinstance(health_payload, Mapping) + else None + ) + exit_code = state.get("ExitCode") + if service in ONE_SHOT_INSTALL_SERVICES: + if status == "exited": + if exit_code != 0: + raise ReleasePackageError( + f"Post-apply migration/init failed for {service}." + ) + elif status in {"created", "running", "restarting"}: + pending_services.add(service) + else: + raise ReleasePackageError( + f"Post-apply migration/init entered an invalid state: {service}." + ) + elif status == "running" and health == "healthy": + pass + elif status in {"created", "running", "restarting"} and health in { + None, + "starting", + "unhealthy", + }: + pending_services.add(service) + else: + raise ReleasePackageError( + f"Post-apply service entered an invalid state: {service}." + ) + env_items = config.get("Env") + if service == "ai-platform-api": + if ( + not isinstance(env_items, list) + or f"AI_PLATFORM_RELEASE_ID={plan['target_release_id']}" not in env_items + ): + raise ReleasePackageError("AI runtime release identity is not bound.") + current_release_binding_verified = True + current_records.append( + { + "service": service, + "container_id_sha256": hashlib.sha256( + container_id.encode("utf-8") + ).hexdigest(), + "status": status, + "health": health, + "exit_code": exit_code, + "image_id": detail.get("Image"), + } + ) + + if not pending_services: + records = current_records + release_binding_verified = current_release_binding_verified + break + if time.monotonic() >= deadline: + raise ReleasePackageError("Post-apply services did not become ready before timeout.") + time.sleep(POST_APPLY_POLL_INTERVAL_SECONDS) if "ai-platform-api" in expected_services and not release_binding_verified: raise ReleasePackageError("Post-apply release identity check did not run.") payload: dict[str, Any] = { @@ -4079,26 +4231,46 @@ def _validate_first_release_install_readiness( def _validate_readiness_runtime_binding( *, readiness: Mapping[str, Any], - evidence: RuntimeEvidence, + env_values: Mapping[str, str], + env_sha256: str, + daemon_identity: Mapping[str, str], tls_binding: Mapping[str, str], project_name: str, ) -> None: bindings = readiness.get("release_environment_and_tls_bindings") target = readiness.get("target") expected_bindings = { - "production_env_sha256": evidence.env_sha256, + "production_env_sha256": env_sha256, "tls_certificate_sha256": tls_binding.get("certificate_sha256"), "tls_private_key_sha256": tls_binding.get("private_key_sha256"), "tls_trusted_ca_sha256": tls_binding.get("trusted_ca_sha256"), } + planner = _load_snapshot_restore_planner_module() + host_identity = getattr(planner, "runtime_host_identity_sha256", None) + if not callable(host_identity): + raise ReleasePackageError( + "The frozen snapshot planner has no runtime host identity algorithm." + ) + expected_target = { + "environment_id": env_values.get("YUQEI_RELEASE_ENVIRONMENT_ID"), + "environment_class": env_values.get("YUQEI_RELEASE_ENVIRONMENT_CLASS"), + "host_id": env_values.get("YUQEI_RELEASE_HOST_ID"), + "host_identity_sha256": host_identity( + env_values.get("YUQEI_RELEASE_HOST_ID"), + daemon_identity, + ), + "docker_daemon_identity_sha256": daemon_identity.get("identity_sha256"), + "compose_project_name": project_name, + } if ( not isinstance(bindings, Mapping) or dict(bindings) != expected_bindings or not isinstance(target, Mapping) - or target.get("compose_project_name") != project_name + or dict(target) != expected_target ): raise ReleasePackageError( - "First-release readiness receipt does not match the exact runtime env/TLS/project." + "First-release readiness receipt does not match the exact runtime " + "environment/host/daemon/TLS/project." ) @@ -4219,14 +4391,9 @@ def execute( if readiness is not None: _validate_readiness_runtime_binding( readiness=readiness, - evidence=RuntimeEvidence( - env_file=Path(plan["env_file"]), - env_values={}, - env_sha256=plan["env_file_sha256"], - preflight_summary=plan["production_preflight"], - compose_gate_summary=plan["compose_merge_gate"], - daemon_identity=plan["docker_daemon_identity"], - ), + env_values=parse_env_file(Path(plan["env_file"])), + env_sha256=plan["env_file_sha256"], + daemon_identity=plan["docker_daemon_identity"], tls_binding=plan["tls_material_binding"], project_name=plan["compose_project_name"], ) @@ -4285,8 +4452,16 @@ def execute( return ExecutionOutcome(pending, exit_code=1, replayed=True) if receipt_path.exists() or receipt_path.is_symlink(): existing = _read_existing_receipt(receipt_path) - if existing.get("idempotency_key") != safe_key or existing.get("plan_sha256") != plan["plan_sha256"]: - raise ReleasePackageError("Idempotency key is already bound to a different release operation.") + if existing.get("idempotency_key") != safe_key: + raise ReleasePackageError("Idempotency receipt is bound to another key.") + if existing.get("execution_mode") != execution_mode: + raise ReleasePackageError( + "Idempotency receipt execution mode does not match the current apply mode." + ) + if existing.get("plan_sha256") != plan["plan_sha256"]: + raise ReleasePackageError( + "Idempotency key is already bound to a different release operation." + ) return ExecutionOutcome(existing, exit_code=0, replayed=True) snapshot = _snapshot_inputs( diff --git a/yuqei-infra/scripts/build-infrastructure-image-bundle.py b/yuqei-infra/scripts/build-infrastructure-image-bundle.py index ee04c823..129896da 100644 --- a/yuqei-infra/scripts/build-infrastructure-image-bundle.py +++ b/yuqei-infra/scripts/build-infrastructure-image-bundle.py @@ -19,12 +19,28 @@ import re import stat import sys import tarfile +from datetime import datetime from pathlib import Path, PurePosixPath from typing import Any, Mapping -SOURCE_SET_SCHEMA = "yuqei.infrastructure-image-source-set.v1" -BUNDLE_SCHEMA = "yuqei.infrastructure-image-bundle.v1" +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from linux_release_common import ( # noqa: E402 + REQUIRED_TOOL_VERSIONS, + TRIVY_DB_REPOSITORY_TRANSPORT, + ReleaseValidationError, + load_policy, + parse_rfc3339, + validate_trivy_db_metadata, + validate_trivy_license_scan, + validate_trivy_scan, +) + +SOURCE_SET_SCHEMA = "yuqei.infrastructure-image-source-set.v2" +BUNDLE_SCHEMA = "yuqei.infrastructure-image-bundle.v2" APPLICATION_MANIFEST_SCHEMA = "yuqei.installable-release-manifest.v1" IMPORT_PLAN_SCHEMA = "yuqei.infrastructure-docker-image-import-plan.v1" TARGET = {"os": "linux", "architecture": "amd64", "platform": "linux/amd64"} @@ -238,6 +254,138 @@ def _file_record(root: Path, value: object, *, label: str) -> tuple[Path, dict[s return target, {"path": relative, "sha256": digest, "size_bytes": size} +def _extended_file_record( + root: Path, + value: object, + *, + extra_fields: set[str], + label: str, +) -> tuple[Path, dict[str, Any], Mapping[str, Any]]: + if not isinstance(value, dict) or set(value) != { + "path", + "sha256", + "size_bytes", + *extra_fields, + }: + raise BundleValidationError(f"{label} record fields are missing or unknown") + base = {key: value[key] for key in ("path", "sha256", "size_bytes")} + target, normalized = _file_record(root, base, label=label) + return target, normalized, value + + +def _validate_database_transport(value: object) -> dict[str, Any]: + if not isinstance(value, dict): + raise BundleValidationError("Trivy database repository transport must be an object") + expected_fields = { + "reference", + "transport", + "schema_version", + "resolved_digest", + "media_type", + "stable_during_download", + "before_download_inspect_sha256", + "after_download_inspect_sha256", + } + if set(value) != expected_fields: + raise BundleValidationError("Trivy database repository transport fields drifted") + if ( + value.get("reference") != TRIVY_DB_REPOSITORY_TRANSPORT["reference"] + or value.get("transport") != TRIVY_DB_REPOSITORY_TRANSPORT["transport"] + or value.get("schema_version") + != TRIVY_DB_REPOSITORY_TRANSPORT["schema_version"] + or value.get("media_type") + not in TRIVY_DB_REPOSITORY_TRANSPORT["allowed_media_types"] + or value.get("stable_during_download") is not True + or not DIGEST_RE.fullmatch(str(value.get("resolved_digest", ""))) + or any( + not SHA256_RE.fullmatch(str(value.get(field, ""))) + for field in ( + "before_download_inspect_sha256", + "after_download_inspect_sha256", + ) + ) + ): + raise BundleValidationError("Trivy database repository transport evidence drifted") + return dict(value) + + +def _validate_security_gate( + root: Path, + value: object, + *, + application_manifest: Mapping[str, Any], +) -> tuple[dict[str, Any], dict[str, Any], Mapping[str, Any]]: + if not isinstance(value, dict) or set(value) != { + "policy", + "database", + "scanner", + "scanner_version", + }: + raise BundleValidationError("infrastructure security gate fields are missing or unknown") + if ( + value.get("scanner") != "trivy" + or value.get("scanner_version") != REQUIRED_TOOL_VERSIONS["trivy"] + ): + raise BundleValidationError("infrastructure scanner identity is not pinned") + policy_path, policy_record = _file_record( + root, + value.get("policy"), + label="infrastructure vulnerability policy", + ) + application_policy = application_manifest.get("policy") + if ( + not isinstance(application_policy, Mapping) + or application_policy.get("sha256") != policy_record["sha256"] + ): + raise BundleValidationError( + "infrastructure security policy differs from the application build policy" + ) + try: + policy = load_policy(policy_path) + except (ReleaseValidationError, OSError) as exc: + raise BundleValidationError( + f"infrastructure vulnerability policy is invalid: {exc}" + ) from exc + database_path, database_record, raw_database_record = _extended_file_record( + root, + value.get("database"), + extra_fields={"validated_at", "repository_transport"}, + label="infrastructure Trivy database metadata", + ) + try: + validated_at = parse_rfc3339( + raw_database_record.get("validated_at"), + label="infrastructure database.validated_at", + ) + database_summary = validate_trivy_db_metadata( + load_json(database_path, label="infrastructure Trivy database metadata"), + policy, + at=validated_at, + ) + except (ReleaseValidationError, BundleValidationError) as exc: + raise BundleValidationError( + f"infrastructure Trivy database evidence is invalid: {exc}" + ) from exc + database_transport = _validate_database_transport( + raw_database_record.get("repository_transport") + ) + return ( + policy, + { + "scanner": "trivy", + "scanner_version": REQUIRED_TOOL_VERSIONS["trivy"], + "policy": policy_record, + "database": { + **database_record, + "validated_at": raw_database_record["validated_at"], + "repository_transport": database_transport, + "summary": database_summary, + }, + }, + load_json(database_path, label="infrastructure Trivy database metadata"), + ) + + def _safe_tar_name(value: str, *, label: str) -> str: if "\\" in value or "\x00" in value: raise BundleValidationError(f"{label} contains an unsafe member path") @@ -727,39 +875,49 @@ def _validate_sbom( } -def _validate_trivy_scan( +def _validate_vulnerability_scan( path: Path, *, role: str, - scan_kind: str, expected_config_digest: str, + expected_artifact_basename: str, + policy: dict[str, Any], + evaluated_at: datetime, ) -> dict[str, Any]: - payload = load_json(path, label=f"{role} {scan_kind}") - if payload.get("SchemaVersion") != 2 or payload.get("ArtifactType") != "container_image": - raise BundleValidationError(f"{role} {scan_kind} must be a Trivy container-image report") - metadata = payload.get("Metadata") - if not isinstance(metadata, dict) or metadata.get("ImageID") != expected_config_digest: - raise BundleValidationError(f"{role} {scan_kind} config image ID mismatch") - results = payload.get("Results") - if not isinstance(results, list) or not results: - raise BundleValidationError(f"{role} {scan_kind} result inventory is missing") - finding_key = "Vulnerabilities" if scan_kind == "vulnerability scan" else "Licenses" - finding_count = 0 - for result in results: - if not isinstance(result, dict): - raise BundleValidationError(f"{role} {scan_kind} result is invalid") - findings = result.get(finding_key, []) - if findings is None: - findings = [] - if not isinstance(findings, list) or any(not isinstance(item, dict) for item in findings): - raise BundleValidationError(f"{role} {scan_kind} findings are invalid") - finding_count += len(findings) - return { - "scanner": "trivy", - "config_digest": expected_config_digest, - "result_count": len(results), - "finding_count": finding_count, - } + payload = load_json(path, label=f"{role} vulnerability scan") + try: + summary = validate_trivy_scan( + payload, + policy, + expected_config_digest=expected_config_digest, + expected_artifact_basename=expected_artifact_basename, + role=role, + at=evaluated_at, + ) + return {**summary, "config_digest": expected_config_digest} + except ReleaseValidationError as exc: + raise BundleValidationError( + f"{role} vulnerability gate failed: {exc}" + ) from exc + + +def _validate_license_scan( + path: Path, + *, + role: str, + expected_config_digest: str, + expected_artifact_basename: str, +) -> dict[str, Any]: + payload = load_json(path, label=f"{role} license scan") + try: + summary = validate_trivy_license_scan( + payload, + expected_config_digest=expected_config_digest, + expected_artifact_basename=expected_artifact_basename, + ) + except ReleaseValidationError as exc: + raise BundleValidationError(f"{role} license gate failed: {exc}") from exc + return {**summary, "config_digest": expected_config_digest} def _validate_application_manifest(root: Path, payload: Mapping[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]: @@ -816,6 +974,15 @@ def build_bundle( if source_set.get("schema_version") != SOURCE_SET_SCHEMA: raise BundleValidationError("unsupported infrastructure source-set schema") _embedded_hash(source_set, "source_set_payload_sha256", label="infrastructure source set") + if set(source_set) != { + "schema_version", + "candidate", + "target", + "security_gate", + "images", + "source_set_payload_sha256", + }: + raise BundleValidationError("infrastructure source-set fields are missing or unknown") if source_set.get("target") != TARGET: raise BundleValidationError("infrastructure source target must be linux/amd64") source_candidate = source_set.get("candidate") @@ -826,16 +993,32 @@ def build_bundle( ) if app_candidate.get("commit") != source_candidate.get("commit") or app_candidate.get("tag") != source_candidate.get("tag"): raise BundleValidationError("application and infrastructure candidate bindings differ") + policy, security_gate, database_payload = _validate_security_gate( + root, + source_set.get("security_gate"), + application_manifest=application_manifest, + ) raw_images = source_set.get("images") if not isinstance(raw_images, list) or len(raw_images) != len(INFRASTRUCTURE_SPECS): raise BundleValidationError("infrastructure source set must contain exactly seven images") seen_archives: set[str] = set() seen_configs: set[str] = set() - seen_security_artifacts: set[str] = set() + seen_security_artifacts: set[str] = { + security_gate["policy"]["path"], + security_gate["database"]["path"], + } seen_registry_blobs: set[str] = set() infrastructure_images: list[dict[str, Any]] = [] import_images: list[dict[str, Any]] = [] + aggregate_vulnerability_counts = { + bucket: { + severity: 0 + for severity in policy["vulnerability_scan"]["thresholds"] + } + for bucket in ("total", "exempted", "blocking") + } + applied_waiver_ids: set[str] = set() overrides = {item["environment_key"]: item["config_digest"] for item in application_images} for sequence, (raw, (role, environment_key, services)) in enumerate( zip(raw_images, INFRASTRUCTURE_SPECS, strict=True), start=1 @@ -937,13 +1120,26 @@ def build_bundle( sbom_path, sbom_record = _file_record( root, raw.get("sbom"), label=f"{role} SBOM" ) - vulnerability_path, vulnerability_record = _file_record( + ( + vulnerability_path, + vulnerability_record, + raw_vulnerability_record, + ) = _extended_file_record( root, raw.get("vulnerability_scan"), + extra_fields={ + "scanner", + "scanner_version", + "waiver_evaluated_at", + "passed", + }, label=f"{role} vulnerability scan", ) - license_path, license_record = _file_record( - root, raw.get("license_scan"), label=f"{role} license scan" + license_path, license_record, raw_license_record = _extended_file_record( + root, + raw.get("license_scan"), + extra_fields={"scanner", "scanner_version"}, + label=f"{role} license scan", ) security_paths = { sbom_record["path"], @@ -958,18 +1154,50 @@ def build_bundle( role=role, expected_manifest_digest=manifest_digest, ) - vulnerability_summary = _validate_trivy_scan( + if ( + raw_vulnerability_record.get("scanner") != "trivy" + or raw_vulnerability_record.get("scanner_version") + != REQUIRED_TOOL_VERSIONS["trivy"] + or raw_vulnerability_record.get("passed") is not True + or raw_license_record.get("scanner") != "trivy" + or raw_license_record.get("scanner_version") + != REQUIRED_TOOL_VERSIONS["trivy"] + ): + raise BundleValidationError(f"{role} scanner identity or pass state drifted") + try: + scan_evaluated_at = parse_rfc3339( + raw_vulnerability_record.get("waiver_evaluated_at"), + label=f"{role} vulnerability waiver_evaluated_at", + ) + validate_trivy_db_metadata( + database_payload, + policy, + at=scan_evaluated_at, + ) + except ReleaseValidationError as exc: + raise BundleValidationError( + f"{role} vulnerability scan database/freshness evidence is invalid: {exc}" + ) from exc + vulnerability_summary = _validate_vulnerability_scan( vulnerability_path, role=role, - scan_kind="vulnerability scan", expected_config_digest=config_digest, + expected_artifact_basename=archive_path.name, + policy=policy, + evaluated_at=scan_evaluated_at, ) - license_summary = _validate_trivy_scan( + license_summary = _validate_license_scan( license_path, role=role, - scan_kind="license scan", expected_config_digest=config_digest, + expected_artifact_basename=archive_path.name, ) + for bucket in aggregate_vulnerability_counts: + for severity in aggregate_vulnerability_counts[bucket]: + aggregate_vulnerability_counts[bucket][severity] += ( + vulnerability_summary["counts"][bucket][severity] + ) + applied_waiver_ids.update(vulnerability_summary["waiver_ids"]) image = { "sequence": sequence, "role": role, @@ -990,9 +1218,21 @@ def build_bundle( "archive_import_validation": "requires_controlled_docker_load_and_inspect_receipt", "sbom": sbom_record, "sbom_summary": sbom_summary, - "vulnerability_scan": vulnerability_record, + "vulnerability_scan": { + **vulnerability_record, + "scanner": "trivy", + "scanner_version": REQUIRED_TOOL_VERSIONS["trivy"], + "waiver_evaluated_at": raw_vulnerability_record[ + "waiver_evaluated_at" + ], + "passed": True, + }, "vulnerability_scan_summary": vulnerability_summary, - "license_scan": license_record, + "license_scan": { + **license_record, + "scanner": "trivy", + "scanner_version": REQUIRED_TOOL_VERSIONS["trivy"], + }, "license_scan_summary": license_summary, } infrastructure_images.append(image) @@ -1043,12 +1283,23 @@ def build_bundle( if tuple(overrides) != ALL_IMAGE_KEYS or set(overrides) != set(ALL_IMAGE_KEYS): raise BundleValidationError("offline override map does not cover exactly all eleven images") + security_gate.update( + { + "thresholds": policy["vulnerability_scan"]["thresholds"], + "exemptions": policy["vulnerability_scan"]["exemptions"], + "vulnerability_counts": aggregate_vulnerability_counts, + "waiver_ids": sorted(applied_waiver_ids), + "passed": True, + "fail_closed": True, + } + ) unsigned: dict[str, Any] = { "schema_version": BUNDLE_SCHEMA, "candidate": {"commit": source_candidate["commit"], "tag": source_candidate["tag"]}, "target": TARGET, "application_images": application_images, "infrastructure_images": infrastructure_images, + "security_gate": security_gate, "offline_compose_overrides": overrides, "all_release_image_keys": list(ALL_IMAGE_KEYS), "docker_import_plan": { diff --git a/yuqei-infra/scripts/build-release-candidate-manifest.py b/yuqei-infra/scripts/build-release-candidate-manifest.py index a3cd587e..f7453c2c 100644 --- a/yuqei-infra/scripts/build-release-candidate-manifest.py +++ b/yuqei-infra/scripts/build-release-candidate-manifest.py @@ -49,6 +49,8 @@ _WINDOWS_GIT_EXECUTABLE = ( ) ) RELEASE_CONTROL_PATHS = ( + ".github/workflows/build-test-quality-gate.yml", + ".github/workflows/security-quality-gate.yml", "yuqei-infra/scripts/build-release-candidate-manifest.py", "yuqei-infra/scripts/build-release-worktree-ownership.py", "yuqei-infra/compose/docker-compose.production.offline.yml", diff --git a/yuqei-infra/scripts/build-release-worktree-ownership.py b/yuqei-infra/scripts/build-release-worktree-ownership.py index 1f503d54..8c631382 100644 --- a/yuqei-infra/scripts/build-release-worktree-ownership.py +++ b/yuqei-infra/scripts/build-release-worktree-ownership.py @@ -80,6 +80,8 @@ RUNTIME_COMPONENTS = { "yuqei-ai-platform/services/ai-platform-api/migrations/", ), "deployment": ( + ".github/workflows/build-test-quality-gate.yml", + ".github/workflows/security-quality-gate.yml", "yuqei-infra/docker/", "yuqei-infra/compose/", "yuqei-infra/nginx/", @@ -107,6 +109,8 @@ RUNTIME_COMPONENTS = { ), } REQUIRED_WORKTREE_ANCHORS = ( + ".github/workflows/build-test-quality-gate.yml", + ".github/workflows/security-quality-gate.yml", "package.json", "package-lock.json", "yuqei-ai-sdk-python/pyproject.toml", diff --git a/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py b/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py index 385e956d..f167a6e7 100644 --- a/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py +++ b/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py @@ -485,6 +485,7 @@ def _case( "environment_class": "production", "host_id": host_id, "host_identity_sha256": "c" * 64, + "docker_daemon_identity_sha256": "d" * 64, "compose_project_name": "yuqei-contract", }, "consistency_set_id": consistency_set_id, diff --git a/yuqei-infra/tests/test_infrastructure_image_bundle.py b/yuqei-infra/tests/test_infrastructure_image_bundle.py index 2fa99833..ee1d4cc3 100644 --- a/yuqei-infra/tests/test_infrastructure_image_bundle.py +++ b/yuqei-infra/tests/test_infrastructure_image_bundle.py @@ -7,6 +7,7 @@ import io import json import sys import tarfile +from datetime import datetime, timezone from pathlib import Path import pytest @@ -22,6 +23,11 @@ assert SPEC is not None and SPEC.loader is not None bundle = importlib.util.module_from_spec(SPEC) sys.modules[SPEC.name] = bundle SPEC.loader.exec_module(bundle) +POLICY_PATH = ( + MODULE_PATH.parents[1] + / "release" + / "linux-release-security-policy.v1.json" +) def _sha(path: Path) -> str: @@ -177,7 +183,7 @@ def _make_security_artifacts( license_path = root / "scans" / f"{role}.license.json" common = { "SchemaVersion": 2, - "ArtifactName": role, + "ArtifactName": f"{role}.docker.tar", "ArtifactType": "container_image", "Metadata": {"ImageID": config_digest}, } @@ -204,21 +210,82 @@ def _make_security_artifacts( "Target": role, "Class": "license", "Type": "image", - "Licenses": [{"Name": "Apache-2.0", "Category": "notice"}], + "Licenses": [ + { + "Name": "Apache-2.0", + "Category": "notice", + "Severity": "LOW", + "PkgName": f"fixture-{role}", + "FilePath": f"usr/share/doc/{role}/copyright", + } + ], } ], }, ) return ( _record(sbom_path, root), - _record(vulnerability_path, root), - _record(license_path, root), + { + **_record(vulnerability_path, root), + "scanner": "trivy", + "scanner_version": bundle.REQUIRED_TOOL_VERSIONS["trivy"], + "waiver_evaluated_at": "2026-09-27T00:05:00Z", + "passed": True, + }, + { + **_record(license_path, root), + "scanner": "trivy", + "scanner_version": bundle.REQUIRED_TOOL_VERSIONS["trivy"], + }, ) +def _make_security_gate(root: Path) -> dict[str, object]: + policy_path = root / "policy/linux-release-security-policy.v1.json" + policy_path.parent.mkdir(parents=True, exist_ok=True) + policy_path.write_bytes(POLICY_PATH.read_bytes()) + database_path = root / "security/trivy-db-metadata.json" + _write_canonical( + database_path, + { + "Version": bundle.REQUIRED_TOOL_VERSIONS["trivy"], + "VulnerabilityDB": { + "Version": 2, + "UpdatedAt": "2026-09-27T00:00:00Z", + "NextUpdate": "2026-09-27T06:00:00Z", + "DownloadedAt": "2026-09-27T00:01:00Z", + }, + }, + ) + return { + "policy": _record(policy_path, root), + "database": { + **_record(database_path, root), + "validated_at": "2026-09-27T00:02:00Z", + "repository_transport": { + "reference": bundle.TRIVY_DB_REPOSITORY_TRANSPORT["reference"], + "transport": bundle.TRIVY_DB_REPOSITORY_TRANSPORT["transport"], + "schema_version": bundle.TRIVY_DB_REPOSITORY_TRANSPORT[ + "schema_version" + ], + "resolved_digest": "sha256:" + "a" * 64, + "media_type": bundle.TRIVY_DB_REPOSITORY_TRANSPORT[ + "allowed_media_types" + ][0], + "stable_during_download": True, + "before_download_inspect_sha256": "b" * 64, + "after_download_inspect_sha256": "c" * 64, + }, + }, + "scanner": "trivy", + "scanner_version": bundle.REQUIRED_TOOL_VERSIONS["trivy"], + } + + def _fixture(root: Path) -> tuple[dict[str, object], dict[str, object]]: commit = "1" * 40 tag = "v2-test-1" + security_gate = _make_security_gate(root) app_images: list[dict[str, object]] = [] for index, (role, environment_key) in enumerate(bundle.APPLICATION_SPECS, start=1): repo_tag = f"yuqei/{role}:{commit}" @@ -240,6 +307,7 @@ def _fixture(root: Path) -> tuple[dict[str, object], dict[str, object]]: { "schema_version": bundle.APPLICATION_MANIFEST_SCHEMA, "candidate": {"commit": commit, "tag": tag}, + "policy": security_gate["policy"], "images": app_images, }, "manifest_payload_sha256", @@ -303,6 +371,7 @@ def _fixture(root: Path) -> tuple[dict[str, object], dict[str, object]]: "schema_version": bundle.SOURCE_SET_SCHEMA, "candidate": {"commit": commit, "tag": tag}, "target": bundle.TARGET, + "security_gate": security_gate, "images": infra_images, }, "source_set_payload_sha256", @@ -605,8 +674,8 @@ def test_every_infrastructure_security_artifact_is_required( ("field", "message"), ( ("sbom", "SBOM root does not bind"), - ("vulnerability_scan", "vulnerability scan config image ID mismatch"), - ("license_scan", "license scan config image ID mismatch"), + ("vulnerability_scan", "vulnerability gate failed"), + ("license_scan", "license gate failed"), ), ) def test_security_artifact_image_identity_drift_is_rejected( @@ -623,7 +692,162 @@ def test_security_artifact_image_identity_drift_is_rejected( else: payload["Metadata"]["ImageID"] = "sha256:" + "f" * 64 _write_canonical(path, payload) - source["images"][0][field] = _record(path, tmp_path) + refreshed = _record(path, tmp_path) + source["images"][0][field] = { + **record, + **refreshed, + } + source = _rehash_source(source) + + with pytest.raises(bundle.BundleValidationError, match=message): + bundle.build_bundle( + artifact_root=tmp_path, + source_set=source, + application_manifest=application, + ) + + +def test_infrastructure_vulnerability_gate_rejects_high_finding_even_when_claimed_pass( + tmp_path: Path, +) -> None: + source, application = _fixture(tmp_path) + record = source["images"][0]["vulnerability_scan"] + path = tmp_path / record["path"] + payload = json.loads(path.read_text(encoding="utf-8")) + payload["Results"][0]["Vulnerabilities"] = [ + { + "VulnerabilityID": "CVE-2099-0001", + "PkgName": "unwaived-package", + "InstalledVersion": "1.0.0", + "FixedVersion": "1.0.1", + "Severity": "HIGH", + } + ] + _write_canonical(path, payload) + source["images"][0]["vulnerability_scan"] = { + **record, + **_record(path, tmp_path), + } + source = _rehash_source(source) + + with pytest.raises(bundle.BundleValidationError, match="vulnerability gate failed"): + bundle.build_bundle( + artifact_root=tmp_path, + source_set=source, + application_manifest=application, + ) + + +@pytest.mark.parametrize( + ("mutation", "message"), + ( + ( + lambda source: source["security_gate"].update( + {"scanner_version": "0.0.0"} + ), + "scanner identity is not pinned", + ), + ( + lambda source: source["images"][0]["vulnerability_scan"].update( + {"waiver_evaluated_at": "2026-09-29T00:05:00Z"} + ), + "database/freshness evidence is invalid", + ), + ), +) +def test_infrastructure_security_gate_rejects_scanner_or_database_drift( + tmp_path: Path, + mutation, + message: str, +) -> None: + source, application = _fixture(tmp_path) + mutation(source) + source = _rehash_source(source) + + with pytest.raises(bundle.BundleValidationError, match=message): + bundle.build_bundle( + artifact_root=tmp_path, + source_set=source, + application_manifest=application, + ) + + +def test_infrastructure_license_inventory_cannot_be_empty(tmp_path: Path) -> None: + source, application = _fixture(tmp_path) + record = source["images"][0]["license_scan"] + path = tmp_path / record["path"] + payload = json.loads(path.read_text(encoding="utf-8")) + payload["Results"][0]["Licenses"] = [] + _write_canonical(path, payload) + source["images"][0]["license_scan"] = { + **record, + **_record(path, tmp_path), + } + source = _rehash_source(source) + + with pytest.raises(bundle.BundleValidationError, match="license inventory is empty"): + bundle.build_bundle( + artifact_root=tmp_path, + source_set=source, + application_manifest=application, + ) + + +@pytest.mark.parametrize( + ("mutation", "message"), + ( + ( + lambda payload: payload.update({"SchemaVersion": 3}), + "SchemaVersion must be exactly 2", + ), + ( + lambda payload: payload["Results"][0].pop("Target"), + "Target is missing", + ), + ( + lambda payload: payload["Results"][0].pop("Class"), + "Class is missing", + ), + ( + lambda payload: payload["Results"][0].pop("Licenses"), + "lacks an explicit Licenses inventory", + ), + ( + lambda payload: payload["Results"][0]["Licenses"][0].pop( + "Category" + ), + "has no category", + ), + ( + lambda payload: payload["Results"][0]["Licenses"][0].pop( + "Severity" + ), + "has no severity", + ), + ( + lambda payload: ( + payload["Results"][0]["Licenses"][0].pop("PkgName"), + payload["Results"][0]["Licenses"][0].pop("FilePath"), + ), + "has no package or file identity", + ), + ), +) +def test_infrastructure_license_report_shape_fails_closed( + tmp_path: Path, + mutation, + message: str, +) -> None: + source, application = _fixture(tmp_path) + record = source["images"][0]["license_scan"] + path = tmp_path / record["path"] + payload = json.loads(path.read_text(encoding="utf-8")) + mutation(payload) + _write_canonical(path, payload) + source["images"][0]["license_scan"] = { + **record, + **_record(path, tmp_path), + } source = _rehash_source(source) with pytest.raises(bundle.BundleValidationError, match=message): diff --git a/yuqei-infra/tests/test_release_candidate_manifest_tools.py b/yuqei-infra/tests/test_release_candidate_manifest_tools.py index f8cef94c..d16c8104 100644 --- a/yuqei-infra/tests/test_release_candidate_manifest_tools.py +++ b/yuqei-infra/tests/test_release_candidate_manifest_tools.py @@ -316,6 +316,15 @@ def test_worktree_classifier_has_explicit_release_ownership(path: str, category: def test_manifest_is_deterministic_and_summarizes_complete_committed_graph(tmp_path: Path) -> None: + release_control_workflows = ( + ".github/workflows/build-test-quality-gate.yml", + ".github/workflows/security-quality-gate.yml", + ) + for release_control_workflow in release_control_workflows: + assert release_control_workflow in manifest.RELEASE_CONTROL_PATHS + assert release_control_workflow in ownership.REQUIRED_WORKTREE_ANCHORS + assert release_control_workflow in ownership.RUNTIME_COMPONENTS["deployment"] + assert ownership.classify_path(release_control_workflow)[0] == "release_source_include" release_control_tests = ( "yuqei-infra/tests/test_production_compose_preflight.py", "yuqei-infra/tests/test_production_compose_merge_gate.py", diff --git a/yuqei-infra/tests/test_release_package_executor.py b/yuqei-infra/tests/test_release_package_executor.py index 59cbf0b3..6b23ad67 100644 --- a/yuqei-infra/tests/test_release_package_executor.py +++ b/yuqei-infra/tests/test_release_package_executor.py @@ -90,6 +90,7 @@ _STRICT_BUILD_TEMPLATE: dict[str, Any] | None = None _REAL_INFRA_TOOL: Any | None = None _INFRA_SYNTHETIC_APPLICATION: dict[str, Any] | None = None _FIXTURE_MIT_TEXT = b"MIT fixture license text\n" +_FIXTURE_APACHE_TEXT = b"Apache-2.0 fixture license text\n" _FIXTURE_GPL_TEXT = b"GPL-2.0-only fixture license text\n" _FIXTURE_SOURCE_BYTES = b"def copyleft_fixture() -> str:\n return 'source'\n" @@ -151,6 +152,13 @@ def _fixture_third_party_trust_baseline(third_tool: Any) -> dict[str, Any]: "aliases": [], **record(_FIXTURE_GPL_TEXT), }, + { + "id": "Apache-2.0", + "kind": "license", + "spdx_expression": "Apache-2.0", + "aliases": [], + **record(_FIXTURE_APACHE_TEXT), + }, ], "source_materials": [ { @@ -359,10 +367,6 @@ def _add_v2_release_artifacts( "release_package_infra_fixture_helpers", "yuqei-infra/tests/test_infrastructure_image_bundle.py", ) - third_helper = _load_test_helper( - "release_package_third_party_fixture_helpers", - "yuqei-infra/tests/test_third_party_materials.py", - ) real_infra_tool = release_package._load_infrastructure_bundle_module() third_tool = release_package._load_third_party_materials_module() third_tool.REPOSITORY_TRUST_BASELINE_PATH = ( @@ -373,6 +377,7 @@ def _add_v2_release_artifacts( commit = installable["candidate"]["commit"] tag = installable["candidate"]["tag"] v2_root.mkdir(parents=True) + infrastructure_security_gate = infra_helper._make_security_gate(v2_root) for image in installable["images"]: relative = Path(image["docker_archive"]["path"]) destination = v2_root / relative @@ -412,6 +417,7 @@ def _add_v2_release_artifacts( _INFRA_SYNTHETIC_APPLICATION = { "schema_version": real_infra_tool.APPLICATION_MANIFEST_SCHEMA, "candidate": {"commit": commit, "tag": tag}, + "policy": infrastructure_security_gate["policy"], "images": synthetic_images, } _INFRA_SYNTHETIC_APPLICATION["manifest_payload_sha256"] = ( @@ -492,6 +498,7 @@ def _add_v2_release_artifacts( "schema_version": infra_tool.SOURCE_SET_SCHEMA, "candidate": {"commit": commit, "tag": tag}, "target": infra_tool.TARGET, + "security_gate": infrastructure_security_gate, "images": infra_images, } source_set["source_set_payload_sha256"] = infra_tool.canonical_sha256(source_set) @@ -510,8 +517,10 @@ def _add_v2_release_artifacts( text_dir = materials_root / "texts" text_dir.mkdir() mit = text_dir / "MIT.txt" + apache = text_dir / "Apache-2.0.txt" gpl = text_dir / "GPL-2.0-only.txt" mit.write_bytes(_FIXTURE_MIT_TEXT) + apache.write_bytes(_FIXTURE_APACHE_TEXT) gpl.write_bytes(_FIXTURE_GPL_TEXT) spdx_licenses = materials_root / "spdx/licenses.json" spdx_exceptions = materials_root / "spdx/exceptions.json" @@ -535,6 +544,8 @@ def _add_v2_release_artifacts( "role": item["role"], "manifest_digest": item["manifest_digest"], "config_digest": item["config_digest"], + "formal_sbom_path": bundle_root / item["sbom"]["path"], + "formal_license_path": bundle_root / item["license_scan"]["path"], } for item in installable["images"] ] + [ @@ -542,8 +553,10 @@ def _add_v2_release_artifacts( "role": item["role"], "manifest_digest": item["manifest_digest"], "config_digest": item["config_digest"], + "formal_sbom_path": v2_root / item["sbom"]["path"], + "formal_license_path": v2_root / item["license_scan"]["path"], } - for item in infra_images + for item in infrastructure_bundle["infrastructure_images"] ] candidate_images = [] occurrences = [] @@ -551,9 +564,10 @@ def _add_v2_release_artifacts( role = item["role"] sbom_path = materials_root / f"reports/{role}.spdx.json" trivy_path = materials_root / f"reports/{role}.trivy-license.json" - _write_json(sbom_path, third_helper._sbom(role, item["manifest_digest"])) - trivy_payload = third_helper._trivy(role, item["config_digest"]) - _write_json(trivy_path, trivy_payload) + sbom_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item["formal_sbom_path"], sbom_path) + shutil.copy2(item["formal_license_path"], trivy_path) + trivy_payload = json.loads(trivy_path.read_text(encoding="utf-8")) trivy_record = _record_for(trivy_path, materials_root) parsed, _summary = third_tool._parse_trivy( trivy_payload, @@ -564,19 +578,23 @@ def _add_v2_release_artifacts( occurrences.extend(parsed) candidate_images.append( { - **item, + "role": role, + "manifest_digest": item["manifest_digest"], + "config_digest": item["config_digest"], "sbom": _record_for(sbom_path, materials_root), "trivy_license": trivy_record, } ) coverage = [] for occurrence in occurrences: + license_name = occurrence["license_name"] + assert license_name in {"Apache-2.0", "GPL-2.0-only", "MIT"} restricted = occurrence["category"] == "restricted" coverage.append( { "occurrence_id": occurrence["occurrence_id"], - "spdx_expression": occurrence["license_name"], - "license_text_ids": ["GPL-2.0-only" if restricted else "MIT"], + "spdx_expression": license_name, + "license_text_ids": [license_name], "source_material_ids": ["fixture-copyleft-source"] if restricted else [], } ) @@ -610,6 +628,10 @@ def _add_v2_release_artifacts( "files": [source_file_record], } source_archive = materials_root / "sources/fixture-copyleft.tar.gz" + third_helper = _load_test_helper( + "release_package_third_party_fixture_helpers", + "yuqei-infra/tests/test_third_party_materials.py", + ) third_helper._source_tar( source_archive, source_manifest=source_archive_manifest, @@ -637,6 +659,13 @@ def _add_v2_release_artifacts( "aliases": [], "file": _record_for(mit, materials_root), }, + { + "id": "Apache-2.0", + "kind": "license", + "spdx_expression": "Apache-2.0", + "aliases": [], + "file": _record_for(apache, materials_root), + }, { "id": "GPL-2.0-only", "kind": "license", @@ -767,9 +796,65 @@ def _strict_build_template(tmp_path: Path) -> dict[str, Any]: return _STRICT_BUILD_TEMPLATE helpers = _load_linux_build_test_helpers() original_source_fixture = helpers._fixture_source + original_license_scan = helpers._license_scan + original_spdx = helpers._spdx + + def release_package_license_scan( + artifact_name: str, + *, + image_id: str, + licenses: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + payload = original_license_scan( + artifact_name, + image_id=image_id, + licenses=licenses, + ) + if artifact_name == "contract-api.docker.tar" and licenses is None: + payload["Results"][0]["Licenses"].append( + { + "Severity": "HIGH", + "Category": "restricted", + "PkgName": "fixture-copyleft", + "FilePath": "usr/share/doc/fixture-copyleft/copyright", + "Name": "GPL-2.0-only", + "Text": "", + "Confidence": 1, + "Link": "https://spdx.org/licenses/GPL-2.0-only.html", + } + ) + return payload + + def release_package_spdx( + role: str, + manifest_digest: str | None = None, + ) -> dict[str, Any]: + payload = original_spdx(role, manifest_digest) + if role == "contract-api": + payload["packages"].append( + { + "SPDXID": "SPDXRef-Package-fixture-copyleft", + "name": "fixture-copyleft", + "versionInfo": "1.0.0", + "licenseDeclared": "GPL-2.0-only", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": ( + "pkg:generic/fixture-copyleft@1.0.0" + ), + } + ], + } + ) + return payload + helpers._fixture_source = lambda path: _extend_source_fixture_with_release_validators( helpers, original_source_fixture, path ) + helpers._license_scan = release_package_license_scan + helpers._spdx = release_package_spdx try: template_source = tmp_path.parent / "release-package-strict-template-source" # A failed first construction must not poison every later case in the @@ -777,8 +862,19 @@ def _strict_build_template(tmp_path: Path) -> dict[str, Any]: if template_source.exists(): shutil.rmtree(template_source) bundle = helpers._bundle(template_source) + receipt_unsigned = dict(bundle["receipt"]) + receipt_unsigned.pop("receipt_payload_sha256") + receipt_unsigned["security"]["license_inventory"]["license_count"] = 5 + bundle["receipt"] = helpers.common.with_payload_digest( + receipt_unsigned, "receipt_payload_sha256" + ) + bundle["receipt_path"].write_bytes( + helpers.common.canonical_json_bytes(bundle["receipt"]) + ) finally: helpers._fixture_source = original_source_fixture + helpers._license_scan = original_license_scan + helpers._spdx = original_spdx receipt_sha256 = _sha(bundle["receipt_path"]) installable = bundle["root"] / "installable-release-manifest.json" assembler = release_package._load_installable_validator_module() @@ -919,6 +1015,9 @@ def _write_strict_production_env( { "COMPOSE_PROJECT_NAME": "yuqei-prod", "RELEASE_ID": "release-2", + "YUQEI_RELEASE_ENVIRONMENT_ID": "production-cn-1", + "YUQEI_RELEASE_ENVIRONMENT_CLASS": "production", + "YUQEI_RELEASE_HOST_ID": "production-host-1", **{key: value["reference"] for key, value in application_images.items()}, **{key: value["reference"] for key, value in infrastructure_images.items()}, "AI_PLATFORM_RELEASE_IMAGE_DIGEST": application_images[ @@ -1622,6 +1721,50 @@ def test_v2_source_bound_third_party_trust_baseline_drift_is_rejected( ) +def test_v2_self_consistent_third_party_report_drift_from_formal_build_is_rejected( + tmp_path: Path, +) -> None: + fixture = _make_package(tmp_path) + materials_input = json.loads( + fixture.third_party_input_file.read_text(encoding="utf-8") + ) + first_image = materials_input["candidate"]["images"][0] + sbom_record = first_image["sbom"] + sbom_path = fixture.third_party_input_file.parent / sbom_record["path"] + sbom_payload = json.loads(sbom_path.read_text(encoding="utf-8")) + sbom_payload["comment"] = "self-consistent alternate report" + _write_json(sbom_path, sbom_payload) + sbom_record.update( + { + "sha256": _sha(sbom_path), + "size_bytes": sbom_path.stat().st_size, + } + ) + _write_json(fixture.third_party_input_file, materials_input) + + materials_module = release_package._load_third_party_materials_module() + output_root = fixture.third_party_manifest_file.parent + shutil.rmtree(output_root) + materials_module.build(fixture.third_party_input_file, output_root) + verification = materials_module.verify( + fixture.third_party_input_file, + output_root, + "internal-hosted", + ) + _write_json(fixture.third_party_receipt_file, verification) + _refresh_outer_manifests(fixture) + + with pytest.raises( + release_package.ReleasePackageError, + match="different raw image report than the formal build gate", + ): + release_package.validate_release_package( + fixture.root, + expected_package_sha256=fixture.expected_sha256, + repo_root=fixture.repo, + ) + + def test_v2_predecessor_recovery_preserves_legacy_plan_semantics(tmp_path: Path) -> None: fixture = _make_package(tmp_path) _use_v2_predecessor_recovery(fixture) @@ -1813,6 +1956,8 @@ def test_v2_first_release_readiness_is_revalidated_before_injected_apply( raw_inputs["trusted_root_public_key"].write_bytes(b"r" * 32) identity = json.loads(fixture.identity_file.read_text(encoding="utf-8")) tls = preplan["tls_material_binding"] + snapshot_planner = release_package._load_snapshot_restore_planner_module() + daemon_identity = preplan["docker_daemon_identity"] readiness = { "schema_version": "yuqei.first-release-recovery-readiness-receipt.v1", "result": release_package.RECOVERY_READINESS_RESULT, @@ -1825,7 +1970,17 @@ def test_v2_first_release_readiness_is_revalidated_before_injected_apply( "source_commit": identity["source_commit"], "image_identity_sha256": _sha(fixture.identity_file), }, - "target": {"compose_project_name": "yuqei-prod"}, + "target": { + "environment_id": "production-cn-1", + "environment_class": "production", + "host_id": "production-host-1", + "host_identity_sha256": snapshot_planner.runtime_host_identity_sha256( + "production-host-1", + daemon_identity, + ), + "docker_daemon_identity_sha256": daemon_identity["identity_sha256"], + "compose_project_name": "yuqei-prod", + }, "release_environment_and_tls_bindings": { "production_env_sha256": preplan["env_file_sha256"], "tls_certificate_sha256": tls["certificate_sha256"], @@ -1836,9 +1991,7 @@ def test_v2_first_release_readiness_is_revalidated_before_injected_apply( receipt_file = tmp_path / "first-release-readiness.json" _write_json(receipt_file, readiness) receipt_sha256 = _sha(receipt_file) - planner_file = Path( - release_package._load_snapshot_restore_planner_module().__file__ - ) + planner_file = Path(snapshot_planner.__file__) validator_calls: list[dict[str, Any]] = [] def validate_install_readiness(**kwargs: Any) -> dict[str, Any]: @@ -1861,6 +2014,9 @@ def test_v2_first_release_readiness_is_revalidated_before_injected_apply( SimpleNamespace( __file__=str(planner_file), validate_install_readiness=validate_install_readiness, + runtime_host_identity_sha256=( + snapshot_planner.runtime_host_identity_sha256 + ), ), ) compose_calls: list[list[str]] = [] @@ -1898,6 +2054,76 @@ def test_v2_first_release_readiness_is_revalidated_before_injected_apply( assert list((state / "receipts").iterdir()) +@pytest.mark.parametrize( + "case", + ("uat_to_production", "host_a_to_b", "daemon_a_to_b"), +) +def test_first_release_readiness_rejects_runtime_target_replay(case: str) -> None: + daemon_identity = { + "docker_cli": release_package.DOCKER_CLI, + "docker_host": release_package.LOCAL_DOCKER_HOST, + "daemon_id": "daemon-a", + "daemon_name": "host-a", + "docker_root_dir": "/var/lib/docker", + "server_version": "26.1.3", + } + daemon_identity["identity_sha256"] = release_package.canonical_sha256( + daemon_identity + ) + planner = release_package._load_snapshot_restore_planner_module() + runtime_env = { + "YUQEI_RELEASE_ENVIRONMENT_ID": "production-cn-1", + "YUQEI_RELEASE_ENVIRONMENT_CLASS": "production", + "YUQEI_RELEASE_HOST_ID": "host-a", + } + target = { + "environment_id": runtime_env["YUQEI_RELEASE_ENVIRONMENT_ID"], + "environment_class": runtime_env["YUQEI_RELEASE_ENVIRONMENT_CLASS"], + "host_id": runtime_env["YUQEI_RELEASE_HOST_ID"], + "host_identity_sha256": planner.runtime_host_identity_sha256( + runtime_env["YUQEI_RELEASE_HOST_ID"], + daemon_identity, + ), + "docker_daemon_identity_sha256": daemon_identity["identity_sha256"], + "compose_project_name": "yuqei-prod", + } + if case == "uat_to_production": + target["environment_class"] = "isolated_uat" + elif case == "host_a_to_b": + target["host_id"] = "host-b" + target["host_identity_sha256"] = planner.runtime_host_identity_sha256( + "host-b", + daemon_identity, + ) + else: + target["docker_daemon_identity_sha256"] = "f" * 64 + + with pytest.raises( + release_package.ReleasePackageError, + match="environment/host/daemon", + ): + release_package._validate_readiness_runtime_binding( + readiness={ + "target": target, + "release_environment_and_tls_bindings": { + "production_env_sha256": "1" * 64, + "tls_certificate_sha256": "2" * 64, + "tls_private_key_sha256": "3" * 64, + "tls_trusted_ca_sha256": "4" * 64, + }, + }, + env_values=runtime_env, + env_sha256="1" * 64, + daemon_identity=daemon_identity, + tls_binding={ + "certificate_sha256": "2" * 64, + "private_key_sha256": "3" * 64, + "trusted_ca_sha256": "4" * 64, + }, + project_name="yuqei-prod", + ) + + @pytest.mark.parametrize( "case", ( @@ -2268,6 +2494,58 @@ def test_apply_is_idempotent_and_conflicting_reuse_fails_closed(tmp_path: Path) assert len(calls) == 1 +def test_injected_success_receipt_cannot_be_replayed_as_builtin_apply( + tmp_path: Path, + monkeypatch, +) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + key = "mode-bound-operation-001" + + injected = _execute( + fixture, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key=key, + state_dir=state, + runner=lambda command, **_kwargs: subprocess.CompletedProcess( + command, + 0, + stdout=b"injected", + stderr=b"", + ), + now=datetime(2026, 9, 20, 4, 0, tzinfo=UTC), + ) + assert injected.payload["execution_mode"] == "test_injected_runner" + + builtin_runner = InspectionRunner() + monkeypatch.setattr(release_package, "_fixed_subprocess_runner", builtin_runner) + monkeypatch.setattr(release_package, "_real_apply_blockers", lambda *_args, **_kwargs: []) + + with pytest.raises(release_package.ReleasePackageError, match="execution mode"): + release_package.execute( + package_dir=fixture.root, + expected_package_sha256=fixture.expected_sha256, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key=key, + state_dir=state, + repo_root=fixture.repo, + runner=builtin_runner, + inspection_runner=builtin_runner, + post_apply_checker=release_package._builtin_post_apply_checker, + now=datetime(2026, 9, 20, 4, 0, tzinfo=UTC), + ) + assert not any( + command[3:6] == ["image", "load", "--input"] + for command, _kwargs in builtin_runner.calls + ) + + def test_exclusive_lock_blocks_concurrent_apply(tmp_path: Path) -> None: fixture = _make_package(tmp_path) state = tmp_path / "state" @@ -2435,6 +2713,124 @@ def test_real_apply_entrypoint_stays_closed_while_external_release_proofs_are_mi assert not state.exists() +def test_builtin_post_apply_checker_binds_every_install_service_image( + tmp_path: Path, + monkeypatch, +) -> None: + image_ids_by_service: dict[str, str] = {} + docker_import_plan: list[dict[str, Any]] = [] + for index, (role, _env_key, services) in enumerate( + release_package.RELEASE_IMPORT_ROLE_ENV_SERVICES, + start=1, + ): + image_id = f"sha256:{index:064x}" + docker_import_plan.append( + { + "role": role, + "expected_loaded_image_id": image_id, + "services": list(services), + } + ) + for service in services: + image_ids_by_service[service] = image_id + + expected_services = list(release_package.INSTALL_SERVICES) + assert "ai-platform-prompt-assets-import" in expected_services + assert set(expected_services).issubset(image_ids_by_service) + container_services = { + f"container-{service}": service for service in expected_services + } + poll_count = {"ps": 0} + sleeps: list[int] = [] + + def runner(command: list[str], **_kwargs: Any): + if "ps" in command: + poll_count["ps"] += 1 + inventory = [ + {"Service": service, "ID": f"container-{service}"} + for service in expected_services + ] + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps(inventory), + stderr="", + ) + container_id = command[-1] + service = container_services[container_id] + one_shot = service in release_package.ONE_SHOT_INSTALL_SERVICES + detail = { + "Image": image_ids_by_service[service], + "Config": { + "Labels": { + "com.docker.compose.project": "yuqei-release-test", + "com.docker.compose.service": service, + }, + "Env": ( + ["AI_PLATFORM_RELEASE_ID=release-test"] + if service == "ai-platform-api" + else [] + ), + }, + "State": { + "Status": "exited" if one_shot else "running", + "ExitCode": 0, + "Health": ( + None + if one_shot + else { + "Status": ( + "starting" + if service == "ai-platform-api" and poll_count["ps"] == 1 + else "healthy" + ) + } + ), + }, + } + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps(detail), + stderr="", + ) + + monkeypatch.setattr(release_package, "_fixed_subprocess_runner", runner) + monkeypatch.setattr(release_package.time, "sleep", sleeps.append) + compose_file = tmp_path / "docker-compose.yml" + result = release_package._builtin_post_apply_checker( + plan={ + "operation": "install", + "target_release_id": "release-test", + "compose_project_name": "yuqei-release-test", + "deployed_service_set": expected_services, + "docker_import_plan": docker_import_plan, + }, + command_argv=[ + release_package.DOCKER_CLI, + "--host", + release_package.LOCAL_DOCKER_HOST, + "compose", + "--project-name", + "yuqei-release-test", + "-f", + str(compose_file), + "up", + "--detach", + ], + runtime_environment={}, + daemon_identity={"identity_sha256": "a" * 64}, + ) + + assert result["status"] == "verified" + assert poll_count["ps"] == 2 + assert sleeps == [release_package.POST_APPLY_POLL_INTERVAL_SECONDS] + assert result["container_count"] == len(expected_services) + assert { + item["service"]: item["image_id"] for item in result["services"] + } == {service: image_ids_by_service[service] for service in expected_services} + + def test_ambient_docker_and_compose_controls_cannot_redirect_apply( tmp_path: Path, monkeypatch, diff --git a/yuqei-infra/tests/test_rollback_rehearsal_planner.py b/yuqei-infra/tests/test_rollback_rehearsal_planner.py index 15a793b7..3ba32946 100644 --- a/yuqei-infra/tests/test_rollback_rehearsal_planner.py +++ b/yuqei-infra/tests/test_rollback_rehearsal_planner.py @@ -1223,3 +1223,76 @@ def test_plan_entrypoint_rejects_relative_release_paths_before_validation( trusted_root_public_key_file=(tmp_path / "root.pub").resolve(), env_file=(tmp_path / "production.env").resolve(), ) + + +def test_plan_entrypoint_uses_each_package_own_verified_validator( + tmp_path: Path, + monkeypatch, +) -> None: + current_package = (tmp_path / "current-package").resolve() + predecessor_package = (tmp_path / "predecessor-package").resolve() + current_repo = (tmp_path / "current-repo").resolve() + predecessor_repo = (tmp_path / "predecessor-repo").resolve() + bootstrap_calls: list[tuple[Path, str, Path]] = [] + load_calls: list[tuple[object, Path]] = [] + validation_calls: list[tuple[Path, Path, str]] = [] + + def bootstrap(*, current_package_dir, current_package_sha256, repo_root): + bootstrap_calls.append( + (current_package_dir, current_package_sha256, repo_root) + ) + marker = repo_root.name + return {"marker": marker.encode()}, {"marker": marker} + + def load(verified_sources, *, verified_repo_root=planner.REPO_ROOT): + marker = verified_sources["marker"].decode() + load_calls.append((verified_sources, verified_repo_root)) + + def validate(package_dir, *, expected_package_sha256, repo_root): + validation_calls.append( + (package_dir, repo_root, expected_package_sha256) + ) + return SimpleNamespace(marker=marker) + + return SimpleNamespace(validate_release_package=validate) + + class StopAfterPackageValidation(RuntimeError): + pass + + monkeypatch.setattr(planner, "_bootstrap_verified_tool_sources", bootstrap) + monkeypatch.setattr(planner, "_load_release_package_module", load) + monkeypatch.setattr( + planner, + "_read_pinned_json", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + StopAfterPackageValidation() + ), + ) + + with pytest.raises(StopAfterPackageValidation): + planner.plan_from_files( + current_package_dir=current_package, + current_package_sha256="a" * 64, + predecessor_package_dir=predecessor_package, + predecessor_package_sha256="b" * 64, + predecessor_repo_root=predecessor_repo, + evidence_file=(tmp_path / "evidence.json").resolve(), + evidence_sha256="c" * 64, + keyring_file=(tmp_path / "keyring.json").resolve(), + keyring_sha256="d" * 64, + compose_gate_evidence_file=(tmp_path / "compose-gate.json").resolve(), + compose_gate_evidence_sha256="e" * 64, + trusted_root_public_key_file=(tmp_path / "root.pub").resolve(), + env_file=(tmp_path / "production.env").resolve(), + repo_root=current_repo, + ) + + assert bootstrap_calls == [ + (current_package, "a" * 64, current_repo), + (predecessor_package, "b" * 64, predecessor_repo), + ] + assert [item[1] for item in load_calls] == [current_repo, predecessor_repo] + assert validation_calls == [ + (current_package, current_repo, "a" * 64), + (predecessor_package, predecessor_repo, "b" * 64), + ]