security: close BUILD-05 release integrity gaps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,6 +2459,24 @@ 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"]
|
||||
expected_services = list(plan["deployed_service_set"])
|
||||
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
|
||||
while True:
|
||||
completed = _fixed_subprocess_runner(
|
||||
ps_command,
|
||||
check=False,
|
||||
@@ -2379,25 +2496,25 @@ def _builtin_post_apply_checker(
|
||||
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.")
|
||||
raise ReleasePackageError(
|
||||
"Compose post-apply inventory repeats or has an invalid 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
|
||||
}
|
||||
records: list[dict[str, Any]] = []
|
||||
release_binding_verified = False
|
||||
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[service]
|
||||
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}.")
|
||||
@@ -2431,18 +2548,43 @@ def _builtin_post_apply_checker(
|
||||
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)
|
||||
or detail.get("Image") != service_image_ids[service]
|
||||
):
|
||||
raise ReleasePackageError(f"Post-apply project or image binding failed for {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
|
||||
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}.")
|
||||
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 (
|
||||
@@ -2450,17 +2592,27 @@ def _builtin_post_apply_checker(
|
||||
or f"AI_PLATFORM_RELEASE_ID={plan['target_release_id']}" not in env_items
|
||||
):
|
||||
raise ReleasePackageError("AI runtime release identity is not bound.")
|
||||
release_binding_verified = True
|
||||
records.append(
|
||||
current_release_binding_verified = True
|
||||
current_records.append(
|
||||
{
|
||||
"service": service,
|
||||
"container_id_sha256": hashlib.sha256(container_id.encode("utf-8")).hexdigest(),
|
||||
"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_values=parse_env_file(Path(plan["env_file"])),
|
||||
env_sha256=plan["env_file_sha256"],
|
||||
preflight_summary=plan["production_preflight"],
|
||||
compose_gate_summary=plan["compose_merge_gate"],
|
||||
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(
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user