release: harden RC-04 Linux artifact chain
This commit is contained in:
@@ -358,6 +358,14 @@ Keep `CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS` at least five seconds above `AI_
|
||||
|
||||
Start from `yuqei-infra\compose\.env.production.example` and replace every `CHANGE_ME` value before deployment.
|
||||
|
||||
The production inventory requires immutable digest references for Contract API,
|
||||
Contract Web, AI Platform API, and AI Worker. The production override clears the
|
||||
shared development `build` definitions with the Compose Specification `!reset`
|
||||
tag. On a host whose images were imported offline, append
|
||||
`compose/docker-compose.production.offline.yml` last so every production service,
|
||||
including `backend-hosted-web` and `ops` profile services, uses
|
||||
`pull_policy: never`. See `deploy/README.md` for the complete file order.
|
||||
|
||||
Run migrations explicitly before replacing application containers.
|
||||
|
||||
The 2-second read cache is process-local and is automatically disabled when `CONTRACT_API_UVICORN_WORKERS` is greater than `1`. Use a shared cache with cross-process invalidation before increasing the production worker count if the same list/detail p95 target must be retained.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
COMPOSE_PROJECT_NAME=yuqei-v2-production
|
||||
RELEASE_ID=CHANGE_ME_IMMUTABLE_RC_TAG
|
||||
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
|
||||
AI_PLATFORM_RELEASE_IMAGE_DIGEST=sha256:CHANGE_ME_64_HEX_DIGEST
|
||||
AI_WORKER_IMAGE=registry.example.com/yuqei/ai-worker@sha256:CHANGE_ME_64_HEX_DIGEST
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
services:
|
||||
postgres:
|
||||
pull_policy: never
|
||||
|
||||
postgres-init-databases:
|
||||
pull_policy: never
|
||||
|
||||
redis:
|
||||
pull_policy: never
|
||||
|
||||
minio:
|
||||
pull_policy: never
|
||||
|
||||
contract-api-migrate:
|
||||
pull_policy: never
|
||||
|
||||
contract-api:
|
||||
pull_policy: never
|
||||
|
||||
contract-review-worker:
|
||||
pull_policy: never
|
||||
|
||||
contract-playbook-governance-worker:
|
||||
pull_policy: never
|
||||
|
||||
ai-platform-api-migrate:
|
||||
pull_policy: never
|
||||
|
||||
ai-platform-prompt-assets-import:
|
||||
pull_policy: never
|
||||
|
||||
ai-platform-api:
|
||||
pull_policy: never
|
||||
|
||||
worker:
|
||||
pull_policy: never
|
||||
|
||||
contract-web:
|
||||
pull_policy: never
|
||||
|
||||
edge:
|
||||
pull_policy: never
|
||||
|
||||
contract-files-backup:
|
||||
pull_policy: never
|
||||
|
||||
minio-backup:
|
||||
pull_policy: never
|
||||
@@ -18,10 +18,12 @@ services:
|
||||
|
||||
contract-api-migrate:
|
||||
image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image}
|
||||
build: !reset null
|
||||
pull_policy: always
|
||||
|
||||
contract-api:
|
||||
image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image}
|
||||
build: !reset null
|
||||
pull_policy: always
|
||||
# SMTP remains opt-in through docker-compose.production.smtp.yml so disabled mode has no secret-file dependency.
|
||||
environment:
|
||||
@@ -38,20 +40,24 @@ services:
|
||||
|
||||
contract-review-worker:
|
||||
image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image}
|
||||
build: !reset null
|
||||
pull_policy: always
|
||||
volumes:
|
||||
- contract-files:/data/contract-files
|
||||
|
||||
contract-playbook-governance-worker:
|
||||
image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image}
|
||||
build: !reset null
|
||||
pull_policy: always
|
||||
|
||||
ai-platform-api-migrate:
|
||||
image: ${AI_PLATFORM_API_IMAGE:?AI_PLATFORM_API_IMAGE must reference an immutable production image}
|
||||
build: !reset null
|
||||
pull_policy: always
|
||||
|
||||
ai-platform-prompt-assets-import:
|
||||
image: ${AI_PLATFORM_API_IMAGE:?AI_PLATFORM_API_IMAGE must reference an immutable production image}
|
||||
build: !reset null
|
||||
pull_policy: always
|
||||
# Production startup validates the packaged assets without publishing them.
|
||||
# An approved publication batch must invoke the importer separately with --apply.
|
||||
@@ -59,6 +65,7 @@ services:
|
||||
|
||||
ai-platform-api:
|
||||
image: ${AI_PLATFORM_API_IMAGE:?AI_PLATFORM_API_IMAGE must reference an immutable production image}
|
||||
build: !reset null
|
||||
pull_policy: always
|
||||
environment:
|
||||
AI_PLATFORM_SECRET_FILE_ROOT: /run/secrets
|
||||
@@ -80,9 +87,13 @@ services:
|
||||
|
||||
worker:
|
||||
image: ${AI_WORKER_IMAGE:?AI_WORKER_IMAGE must reference an immutable production image}
|
||||
build: !reset null
|
||||
pull_policy: always
|
||||
|
||||
contract-web:
|
||||
image: ${CONTRACT_WEB_IMAGE:?CONTRACT_WEB_IMAGE must reference an immutable production image}
|
||||
build: !reset null
|
||||
pull_policy: always
|
||||
profiles: ["backend-hosted-web"]
|
||||
|
||||
edge:
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
# V2 production backend deployment
|
||||
|
||||
The production backend runs on `192.168.3.90`; Contract Web is deployed separately to Alibaba Cloud. The production compose override therefore disables Contract Web by default and exposes only the HTTPS edge service publicly.
|
||||
The production backend runs on `192.168.3.90`; Contract Web is deployed separately to Alibaba Cloud. The production compose override therefore keeps Contract Web behind the opt-in `backend-hosted-web` profile and exposes only the HTTPS edge service publicly by default. Its image is still required and digest-pinned so every production profile has one immutable image inventory.
|
||||
|
||||
## Files
|
||||
|
||||
- `../compose/docker-compose.yml`: shared service definitions used by local and smoke environments.
|
||||
- `../compose/docker-compose.production.yml`: backend-only production image, volume, TLS edge, and backup-helper overrides.
|
||||
- `../compose/docker-compose.production.yml`: production image, volume, TLS edge, and backup-helper overrides. It uses the Compose Specification `!reset null` tag to remove every application `build` inherited from the shared file.
|
||||
- `../compose/docker-compose.production.offline.yml`: final, offline-only override that forbids registry pulls for every service that production can enable, including optional profiles.
|
||||
- `../compose/docker-compose.production.smtp.yml`: optional SMTP password secret mount; include only when email delivery mode is `smtp`.
|
||||
- `../compose/.env.production.example`: reviewed variable inventory. The real file belongs at `/etc/yuqei/production.env`, mode `0600`, outside the repository.
|
||||
- `preflight-production.py`: read-only production configuration gate.
|
||||
- `verify-production-compose.py`: read-only merged-model gate. It renders Docker Compose JSON for online and offline modes across the default, `backend-hosted-web`, `ops`, and combined profile sets.
|
||||
- `release-package.py`: backend-only release-package verifier and planner. Its production apply entry point remains deliberately closed until the complete infrastructure-image import plan and externally trusted backup/compatibility approvals are available.
|
||||
|
||||
## Prepare without changing traffic
|
||||
|
||||
1. Create an immutable RC commit and publish the three application images by digest. Pin PostgreSQL/pgvector, Redis, MinIO, mc, Nginx, and the backup helper by digest as well.
|
||||
1. Create an immutable RC commit and publish the four application images by digest: Contract API, Contract Web, AI Platform API, and AI Worker. Pin PostgreSQL/pgvector, Redis, MinIO, mc, Nginx, and the backup helper by digest as well.
|
||||
2. Create a new production PostgreSQL/volume set. Do not reuse or rename the smoke database or volumes.
|
||||
3. Mount an off-host backup filesystem at `BACKUP_HOST_DIR`.
|
||||
4. Place `tls.crt`, `tls.key`, and the AI provider key in protected host directories. When enabling SMTP, also create the file named by `CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST`, mode `0600`.
|
||||
@@ -29,15 +32,11 @@ python3 yuqei-infra/deploy/preflight-production.py \
|
||||
--artifact-dir /var/lib/yuqei/preflight
|
||||
```
|
||||
|
||||
7. Validate and pull images without starting services:
|
||||
7. Validate the merged Compose models, then pull online images without starting services:
|
||||
|
||||
```bash
|
||||
docker compose \
|
||||
--project-name yuqei-v2-production \
|
||||
--env-file /etc/yuqei/production.env \
|
||||
-f yuqei-infra/compose/docker-compose.yml \
|
||||
-f yuqei-infra/compose/docker-compose.production.yml \
|
||||
config --quiet
|
||||
python3 yuqei-infra/deploy/verify-production-compose.py \
|
||||
--env-file /etc/yuqei/production.env
|
||||
|
||||
docker compose \
|
||||
--project-name yuqei-v2-production \
|
||||
@@ -47,7 +46,39 @@ docker compose \
|
||||
pull
|
||||
```
|
||||
|
||||
These commands do not start containers and do not switch traffic.
|
||||
The merge gate invokes only `docker compose config --format json`; it does not
|
||||
start, build, pull, or remove anything. The separate `pull` command downloads
|
||||
the already approved online image references, but does not start containers or
|
||||
switch traffic.
|
||||
|
||||
Use a current Docker Compose V2 release that implements the Compose Specification
|
||||
`!reset` tag. A successful `config --quiet` invocation proves only that Compose
|
||||
can parse and interpolate the files; it does not prove the merged image, build,
|
||||
or pull-policy content. The JSON merge gate performs those semantic checks.
|
||||
|
||||
For an offline host, import every approved image into the local engine through
|
||||
the approved artifact-transfer procedure. The JSON merge gate always appends
|
||||
`docker-compose.production.offline.yml` after the base and production files when
|
||||
it verifies offline mode.
|
||||
|
||||
Pass `--profile backend-hosted-web` and/or `--profile ops` when those services are
|
||||
intended. The offline override sets `pull_policy: never` for all services in every
|
||||
core production profile, so a missing local image fails closed instead of contacting
|
||||
a registry. A companion overlay that introduces another image-bearing service needs
|
||||
an explicit offline pull policy of its own before it is approved for this path. Do
|
||||
not add the offline override to the online `pull` command.
|
||||
|
||||
`release-package.py` validates the current four-application Docker import plan,
|
||||
including archive size/SHA-256 and the expected config-based image IDs used for
|
||||
offline Compose overrides. That is not yet a clean-host offline installation
|
||||
closure: PostgreSQL, the PostgreSQL client, Redis, MinIO, mc, Nginx, and the
|
||||
backup helper do not all have approved import archives, and the packaged backup
|
||||
and rollback-compatibility records are only self-hashed rather than externally
|
||||
signed. Consequently `verify` reports structural validity without apply
|
||||
eligibility, plans expose the blocking reasons, and the normal `--apply` entry
|
||||
point fails before creating state or loading an image. Contract Web remains an
|
||||
identity-bound, separately deployed artifact; this backend executor never claims
|
||||
that the full cross-host release has been installed or rolled back.
|
||||
|
||||
The production override also replaces `ai-platform-prompt-assets-import` with the
|
||||
importer's default dry-run command. That Compose service may validate and report the
|
||||
|
||||
@@ -124,6 +124,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False)
|
||||
"COMPOSE_PROJECT_NAME",
|
||||
"RELEASE_ID",
|
||||
"CONTRACT_API_IMAGE",
|
||||
"CONTRACT_WEB_IMAGE",
|
||||
"AI_PLATFORM_API_IMAGE",
|
||||
"AI_PLATFORM_RELEASE_IMAGE_DIGEST",
|
||||
"AI_WORKER_IMAGE",
|
||||
@@ -197,6 +198,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False)
|
||||
|
||||
image_keys = (
|
||||
"CONTRACT_API_IMAGE",
|
||||
"CONTRACT_WEB_IMAGE",
|
||||
"AI_PLATFORM_API_IMAGE",
|
||||
"AI_WORKER_IMAGE",
|
||||
"POSTGRES_IMAGE",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed validation of the merged production Compose data model.
|
||||
|
||||
This gate runs only ``docker compose config --format json``. It never starts,
|
||||
builds, pulls, or removes containers or images. Both the online stack and the
|
||||
offline stack are rendered for every supported production profile combination
|
||||
so validation is performed on Compose's merged model rather than on source
|
||||
YAML text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCKER_CLI = "/usr/bin/docker"
|
||||
LOCAL_DOCKER_HOST = "unix:///var/run/docker.sock"
|
||||
BASE_COMPOSE_RELATIVE = "yuqei-infra/compose/docker-compose.yml"
|
||||
PRODUCTION_COMPOSE_RELATIVE = "yuqei-infra/compose/docker-compose.production.yml"
|
||||
OFFLINE_COMPOSE_RELATIVE = "yuqei-infra/compose/docker-compose.production.offline.yml"
|
||||
|
||||
SCHEMA_VERSION = "yuqei.production-compose-merge-gate.v1"
|
||||
MAX_ENV_BYTES = 1024 * 1024
|
||||
MAX_CONFIG_BYTES = 8 * 1024 * 1024
|
||||
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
PROJECT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$")
|
||||
IMAGE_REFERENCE_RE = re.compile(
|
||||
r"^[a-z0-9](?:[a-z0-9._:/-]{0,510}[a-z0-9])?@sha256:[0-9a-f]{64}$"
|
||||
)
|
||||
|
||||
APPLICATION_SERVICE_IMAGE_ENV: Mapping[str, str] = {
|
||||
"contract-api-migrate": "CONTRACT_API_IMAGE",
|
||||
"contract-api": "CONTRACT_API_IMAGE",
|
||||
"contract-review-worker": "CONTRACT_API_IMAGE",
|
||||
"contract-playbook-governance-worker": "CONTRACT_API_IMAGE",
|
||||
"ai-platform-api-migrate": "AI_PLATFORM_API_IMAGE",
|
||||
"ai-platform-prompt-assets-import": "AI_PLATFORM_API_IMAGE",
|
||||
"ai-platform-api": "AI_PLATFORM_API_IMAGE",
|
||||
"worker": "AI_WORKER_IMAGE",
|
||||
"contract-web": "CONTRACT_WEB_IMAGE",
|
||||
}
|
||||
|
||||
PRODUCTION_SERVICES = (
|
||||
"postgres",
|
||||
"postgres-init-databases",
|
||||
"redis",
|
||||
"minio",
|
||||
"contract-api-migrate",
|
||||
"contract-api",
|
||||
"contract-review-worker",
|
||||
"contract-playbook-governance-worker",
|
||||
"ai-platform-api-migrate",
|
||||
"ai-platform-prompt-assets-import",
|
||||
"ai-platform-api",
|
||||
"worker",
|
||||
"contract-web",
|
||||
"edge",
|
||||
"contract-files-backup",
|
||||
"minio-backup",
|
||||
)
|
||||
|
||||
PROFILE_ONLY_SERVICES: Mapping[str, str] = {
|
||||
"contract-web": "backend-hosted-web",
|
||||
"contract-files-backup": "ops",
|
||||
"minio-backup": "ops",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProfileCase:
|
||||
name: str
|
||||
profiles: tuple[str, ...]
|
||||
|
||||
|
||||
PROFILE_CASES = (
|
||||
ProfileCase("default", ()),
|
||||
ProfileCase("backend-hosted-web", ("backend-hosted-web",)),
|
||||
ProfileCase("ops", ("ops",)),
|
||||
ProfileCase("backend-hosted-web+ops", ("backend-hosted-web", "ops")),
|
||||
)
|
||||
|
||||
|
||||
class ComposeGateError(RuntimeError):
|
||||
"""Raised when production Compose validation fails closed."""
|
||||
|
||||
|
||||
Runner = Callable[..., subprocess.CompletedProcess[Any]]
|
||||
|
||||
|
||||
def _canonical_json_bytes(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
except OSError as exc:
|
||||
raise ComposeGateError(f"Cannot read required file: {path}") from exc
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _validated_file(path: Path, *, label: str, max_bytes: int | None = None) -> Path:
|
||||
candidate = Path(path)
|
||||
if candidate.is_symlink():
|
||||
raise ComposeGateError(f"{label} must not be a symbolic link.")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
stat = resolved.stat()
|
||||
except OSError as exc:
|
||||
raise ComposeGateError(f"{label} is missing or unreadable: {candidate}") from exc
|
||||
if not resolved.is_file():
|
||||
raise ComposeGateError(f"{label} must be a regular file.")
|
||||
if max_bytes is not None and stat.st_size > max_bytes:
|
||||
raise ComposeGateError(f"{label} exceeds the maximum allowed size.")
|
||||
return resolved
|
||||
|
||||
|
||||
def _compose_inputs(repo_root: Path) -> tuple[Path, Path, Path]:
|
||||
try:
|
||||
root = Path(repo_root).resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise ComposeGateError("Repository root is missing or unreadable.") from exc
|
||||
if not root.is_dir():
|
||||
raise ComposeGateError("Repository root must be a directory.")
|
||||
|
||||
results: list[Path] = []
|
||||
for label, relative in (
|
||||
("base Compose file", BASE_COMPOSE_RELATIVE),
|
||||
("production Compose file", PRODUCTION_COMPOSE_RELATIVE),
|
||||
("offline Compose override", OFFLINE_COMPOSE_RELATIVE),
|
||||
):
|
||||
candidate = root / relative
|
||||
validated = _validated_file(candidate, label=label, max_bytes=MAX_CONFIG_BYTES)
|
||||
try:
|
||||
validated.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise ComposeGateError(f"{label} escapes the repository root.") from exc
|
||||
results.append(validated)
|
||||
return results[0], results[1], results[2]
|
||||
|
||||
|
||||
def parse_strict_env(path: Path) -> tuple[Path, dict[str, str]]:
|
||||
target = _validated_file(path, label="production env file", max_bytes=MAX_ENV_BYTES)
|
||||
try:
|
||||
text = target.read_text(encoding="utf-8-sig")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise ComposeGateError("Production env file must be readable UTF-8 text.") from exc
|
||||
|
||||
values: dict[str, str] = {}
|
||||
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("export ") or "=" not in line:
|
||||
raise ComposeGateError(f"Invalid strict env syntax at line {line_number}.")
|
||||
raw_key, raw_value = line.split("=", 1)
|
||||
if raw_key != raw_key.strip():
|
||||
raise ComposeGateError(f"Invalid strict env key at line {line_number}.")
|
||||
key = raw_key
|
||||
if not ENV_KEY_RE.fullmatch(key) or key in values:
|
||||
raise ComposeGateError(f"Invalid or duplicate strict env key at line {line_number}.")
|
||||
values[key] = raw_value.strip()
|
||||
|
||||
project_name = values.get("COMPOSE_PROJECT_NAME")
|
||||
if project_name is None or not PROJECT_RE.fullmatch(project_name):
|
||||
raise ComposeGateError("COMPOSE_PROJECT_NAME is missing or unsafe in the strict env file.")
|
||||
|
||||
for env_key in sorted(set(APPLICATION_SERVICE_IMAGE_ENV.values())):
|
||||
image = values.get(env_key)
|
||||
if image is None or not IMAGE_REFERENCE_RE.fullmatch(image):
|
||||
raise ComposeGateError(
|
||||
f"{env_key} must be an explicit lowercase immutable @sha256 image "
|
||||
"in the strict env file."
|
||||
)
|
||||
return target, values
|
||||
|
||||
|
||||
def _reject_json_constant(value: str) -> None:
|
||||
raise ComposeGateError(f"Compose JSON contains forbidden numeric constant {value}.")
|
||||
|
||||
|
||||
def _json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ComposeGateError(f"Compose JSON contains duplicate key {key!r}.")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _parse_compose_json(stdout: Any) -> dict[str, Any]:
|
||||
if isinstance(stdout, bytes):
|
||||
try:
|
||||
raw = stdout.decode("utf-8")
|
||||
except UnicodeError as exc:
|
||||
raise ComposeGateError("Docker Compose emitted non-UTF-8 JSON.") from exc
|
||||
elif isinstance(stdout, str):
|
||||
raw = stdout
|
||||
else:
|
||||
raise ComposeGateError("Docker Compose did not emit textual JSON.")
|
||||
if len(raw.encode("utf-8")) > MAX_CONFIG_BYTES:
|
||||
raise ComposeGateError("Docker Compose JSON exceeds the maximum allowed size.")
|
||||
try:
|
||||
payload = json.loads(
|
||||
raw,
|
||||
object_pairs_hook=_json_object,
|
||||
parse_constant=_reject_json_constant,
|
||||
)
|
||||
except ComposeGateError:
|
||||
raise
|
||||
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise ComposeGateError("Docker Compose did not emit valid JSON.") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ComposeGateError("Docker Compose JSON must be an object.")
|
||||
return payload
|
||||
|
||||
|
||||
def _required_services(profiles: Sequence[str]) -> set[str]:
|
||||
active = set(profiles)
|
||||
return {
|
||||
service
|
||||
for service in PRODUCTION_SERVICES
|
||||
if service not in PROFILE_ONLY_SERVICES
|
||||
or PROFILE_ONLY_SERVICES[service] in active
|
||||
}
|
||||
|
||||
|
||||
def _validate_model(
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
env_values: Mapping[str, str],
|
||||
mode: str,
|
||||
profile_case: ProfileCase,
|
||||
) -> dict[str, Any]:
|
||||
services = payload.get("services")
|
||||
if not isinstance(services, dict):
|
||||
raise ComposeGateError(
|
||||
f"{mode}/{profile_case.name}: merged Compose model has no services object."
|
||||
)
|
||||
|
||||
actual_names = set(services)
|
||||
allowed_names = set(PRODUCTION_SERVICES)
|
||||
unexpected = sorted(actual_names - allowed_names)
|
||||
if unexpected:
|
||||
raise ComposeGateError(
|
||||
f"{mode}/{profile_case.name}: unexpected production services: {', '.join(unexpected)}."
|
||||
)
|
||||
missing = sorted(_required_services(profile_case.profiles) - actual_names)
|
||||
if missing:
|
||||
raise ComposeGateError(
|
||||
f"{mode}/{profile_case.name}: required services are missing: {', '.join(missing)}."
|
||||
)
|
||||
|
||||
validated_application_services: list[str] = []
|
||||
for service_name, env_key in APPLICATION_SERVICE_IMAGE_ENV.items():
|
||||
if service_name not in services:
|
||||
continue
|
||||
service = services[service_name]
|
||||
if not isinstance(service, dict):
|
||||
raise ComposeGateError(
|
||||
f"{mode}/{profile_case.name}: service {service_name} is not an object."
|
||||
)
|
||||
if service.get("image") != env_values[env_key]:
|
||||
raise ComposeGateError(
|
||||
f"{mode}/{profile_case.name}: service {service_name} image does not "
|
||||
f"exactly match {env_key}."
|
||||
)
|
||||
if service.get("build") is not None:
|
||||
raise ComposeGateError(
|
||||
f"{mode}/{profile_case.name}: service {service_name} retains a "
|
||||
"build definition."
|
||||
)
|
||||
expected_pull = "never" if mode == "offline" else "always"
|
||||
if service.get("pull_policy") != expected_pull:
|
||||
raise ComposeGateError(
|
||||
f"{mode}/{profile_case.name}: service {service_name} pull_policy "
|
||||
f"must be {expected_pull}."
|
||||
)
|
||||
validated_application_services.append(service_name)
|
||||
|
||||
if mode == "offline":
|
||||
for service_name in sorted(actual_names):
|
||||
service = services[service_name]
|
||||
if not isinstance(service, dict):
|
||||
raise ComposeGateError(
|
||||
f"offline/{profile_case.name}: service {service_name} is not an object."
|
||||
)
|
||||
if service.get("build") is not None:
|
||||
raise ComposeGateError(
|
||||
f"offline/{profile_case.name}: service {service_name} retains "
|
||||
"a build definition."
|
||||
)
|
||||
if service.get("pull_policy") != "never":
|
||||
raise ComposeGateError(
|
||||
f"offline/{profile_case.name}: service {service_name} "
|
||||
"pull_policy must be never."
|
||||
)
|
||||
|
||||
return {
|
||||
"mode": mode,
|
||||
"profile_case": profile_case.name,
|
||||
"profiles": list(profile_case.profiles),
|
||||
"service_count": len(actual_names),
|
||||
"validated_application_services": validated_application_services,
|
||||
"config_sha256": hashlib.sha256(_canonical_json_bytes(payload)).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def _command(
|
||||
*,
|
||||
project_name: str,
|
||||
env_file: Path,
|
||||
compose_files: Sequence[Path],
|
||||
profile_case: ProfileCase,
|
||||
) -> list[str]:
|
||||
command = [
|
||||
str(DOCKER_CLI),
|
||||
"--host",
|
||||
LOCAL_DOCKER_HOST,
|
||||
"compose",
|
||||
"--project-name",
|
||||
project_name,
|
||||
"--env-file",
|
||||
str(env_file),
|
||||
]
|
||||
for compose_file in compose_files:
|
||||
command.extend(("-f", str(compose_file)))
|
||||
for profile in profile_case.profiles:
|
||||
command.extend(("--profile", profile))
|
||||
command.extend(("config", "--format", "json"))
|
||||
return command
|
||||
|
||||
|
||||
def _safe_subprocess_env(env_values: Mapping[str, str]) -> dict[str, str]:
|
||||
# The env file is the only interpolation source. In particular, an
|
||||
# ambient Docker context, daemon, Compose file/profile, config directory,
|
||||
# or executable search path must not redirect this read-only gate.
|
||||
_ = env_values
|
||||
return {
|
||||
"HOME": "/var/empty",
|
||||
"LANG": "C.UTF-8",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
}
|
||||
|
||||
|
||||
def _run_config(
|
||||
command: list[str],
|
||||
*,
|
||||
repo_root: Path,
|
||||
runner: Runner,
|
||||
child_env: Mapping[str, str],
|
||||
label: str,
|
||||
) -> dict[str, Any]:
|
||||
if command[:4] != [str(DOCKER_CLI), "--host", LOCAL_DOCKER_HOST, "compose"]:
|
||||
raise ComposeGateError("Internal error: Docker target is not the fixed local daemon.")
|
||||
if command[-3:] != ["config", "--format", "json"]:
|
||||
raise ComposeGateError("Internal error: only Compose config JSON commands are permitted.")
|
||||
if any(token in {"up", "pull", "build", "down", "run", "exec"} for token in command):
|
||||
raise ComposeGateError("Internal error: a mutating Compose command was constructed.")
|
||||
try:
|
||||
completed = runner(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=False,
|
||||
cwd=str(repo_root),
|
||||
env=dict(child_env),
|
||||
timeout=60,
|
||||
)
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise ComposeGateError(f"{label}: Docker Compose config could not be executed.") from exc
|
||||
if completed.returncode != 0:
|
||||
stderr = completed.stderr
|
||||
if isinstance(stderr, bytes):
|
||||
stderr = stderr.decode("utf-8", errors="replace")
|
||||
detail = str(stderr or "").strip().replace("\r", " ").replace("\n", " ")[:500]
|
||||
suffix = f": {detail}" if detail else ""
|
||||
raise ComposeGateError(
|
||||
f"{label}: Docker Compose config failed with exit code {completed.returncode}{suffix}"
|
||||
)
|
||||
return _parse_compose_json(completed.stdout)
|
||||
|
||||
|
||||
def verify_production_compose(
|
||||
*,
|
||||
env_file: Path,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
runner: Runner = subprocess.run,
|
||||
) -> dict[str, Any]:
|
||||
"""Render and validate every supported online/offline profile combination."""
|
||||
|
||||
env_target, env_values = parse_strict_env(env_file)
|
||||
try:
|
||||
root = Path(repo_root).resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise ComposeGateError("Repository root is missing or unreadable.") from exc
|
||||
base, production, offline = _compose_inputs(root)
|
||||
child_env = _safe_subprocess_env(env_values)
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
for profile_case in PROFILE_CASES:
|
||||
for mode, compose_files in (
|
||||
("online", (base, production)),
|
||||
("offline", (base, production, offline)),
|
||||
):
|
||||
command = _command(
|
||||
project_name=env_values["COMPOSE_PROJECT_NAME"],
|
||||
env_file=env_target,
|
||||
compose_files=compose_files,
|
||||
profile_case=profile_case,
|
||||
)
|
||||
payload = _run_config(
|
||||
command,
|
||||
repo_root=root,
|
||||
runner=runner,
|
||||
child_env=child_env,
|
||||
label=f"{mode}/{profile_case.name}",
|
||||
)
|
||||
results.append(
|
||||
_validate_model(
|
||||
payload,
|
||||
env_values=env_values,
|
||||
mode=mode,
|
||||
profile_case=profile_case,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"status": "passed",
|
||||
"env_file_sha256": _sha256(env_target),
|
||||
"compose_inputs": [
|
||||
{"path": path.relative_to(root).as_posix(), "sha256": _sha256(path)}
|
||||
for path in (base, production, offline)
|
||||
],
|
||||
"application_service_count": len(APPLICATION_SERVICE_IMAGE_ENV),
|
||||
"production_service_count": len(PRODUCTION_SERVICES),
|
||||
"cases": results,
|
||||
}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read-only gate for merged online/offline production Compose models."
|
||||
)
|
||||
parser.add_argument("--env-file", required=True, type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
report = verify_production_compose(env_file=args.env_file)
|
||||
except ComposeGateError as exc:
|
||||
print(f"production Compose gate failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(_canonical_json_bytes(report).decode("utf-8"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -18,7 +18,24 @@ COPY yuqei-contract-platform/packages/design-system yuqei-contract-platform/pack
|
||||
|
||||
RUN npm --workspace @yuqei/contract-web run build
|
||||
|
||||
# npm is required only while assembling the Next.js bundle. Do not ship the
|
||||
# package-manager CLI (and its independent dependency tree) in the runtime
|
||||
# image; start the already installed Next.js CLI with Node directly instead.
|
||||
RUN rm -rf \
|
||||
/usr/local/lib/node_modules/npm \
|
||||
/usr/local/lib/node_modules/corepack \
|
||||
/usr/local/bin/npm \
|
||||
/usr/local/bin/npx \
|
||||
/usr/local/bin/corepack \
|
||||
/usr/local/bin/yarn \
|
||||
/usr/local/bin/yarnpkg \
|
||||
/usr/local/bin/pnpm \
|
||||
/opt/yarn* \
|
||||
&& chown -R node:node /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "--workspace", "@yuqei/contract-web", "run", "start"]
|
||||
CMD ["node", "/app/node_modules/next/dist/bin/next", "start", "/app/yuqei-contract-platform/apps/contract-web", "-p", "3000"]
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
{
|
||||
"schema_version": "yuqei.linux-release-security-policy.v1",
|
||||
"target": {
|
||||
"architecture": "amd64",
|
||||
"os": "linux",
|
||||
"platform": "linux/amd64"
|
||||
},
|
||||
"builder": {
|
||||
"allowed_drivers": [
|
||||
"docker-container"
|
||||
],
|
||||
"maximum_nodes": 1,
|
||||
"required_buildkit_version": "0.13.2",
|
||||
"required_driver_image": "docker.m.daocloud.io/moby/buildkit@sha256:9194b5ec1be368f41c516df7f93f7f540630ea06136056b2ffebb62226ed4ad6",
|
||||
"required_driver_image_digest": "sha256:9194b5ec1be368f41c516df7f93f7f540630ea06136056b2ffebb62226ed4ad6",
|
||||
"required_exporters": [
|
||||
"oci",
|
||||
"docker"
|
||||
],
|
||||
"required_local_driver_image_id": "sha256:1a21071780b069d68bd7ef277675f1c5273fee488cec145494cf072777260797",
|
||||
"required_platform": "linux/amd64"
|
||||
},
|
||||
"compose_gate": {
|
||||
"case_count": 8,
|
||||
"compose_inputs": [
|
||||
"yuqei-infra/compose/docker-compose.yml",
|
||||
"yuqei-infra/compose/docker-compose.production.yml",
|
||||
"yuqei-infra/compose/docker-compose.production.offline.yml"
|
||||
],
|
||||
"production_secrets_forbidden": true,
|
||||
"schema_version": "yuqei.production-compose-merge-gate.v1",
|
||||
"synthetic_environment_required": true,
|
||||
"validator": "yuqei-infra/deploy/verify-production-compose.py"
|
||||
},
|
||||
"execution": {
|
||||
"command_timeouts_seconds": {
|
||||
"base_image_inspect": 120,
|
||||
"builder_inspect": 120,
|
||||
"compose_gate": 600,
|
||||
"container_inspect": 30,
|
||||
"git_validation": 30,
|
||||
"image_build": 3600,
|
||||
"sbom_generation": 600,
|
||||
"tool_version": 30,
|
||||
"trivy_db_download": 600,
|
||||
"trivy_db_inspect": 120,
|
||||
"vulnerability_scan": 900
|
||||
},
|
||||
"controlled_environment": {
|
||||
"BUILDX_CONFIG": "/root/.docker/buildx",
|
||||
"DOCKER_CONFIG": "/var/empty/docker",
|
||||
"HOME": "/var/empty",
|
||||
"LANG": "C.UTF-8",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"PATH": "/usr/bin:/bin"
|
||||
},
|
||||
"docker_host": "unix:///var/run/docker.sock",
|
||||
"pinned_executables": {
|
||||
"docker": "/usr/bin/docker",
|
||||
"git": "/usr/bin/git",
|
||||
"syft": "/usr/bin/syft",
|
||||
"trivy": "/usr/bin/trivy"
|
||||
}
|
||||
},
|
||||
"toolchain": {
|
||||
"buildx": {
|
||||
"allowed_versions": [
|
||||
"0.14.0"
|
||||
]
|
||||
},
|
||||
"compose": {
|
||||
"allowed_versions": [
|
||||
"2.27.0"
|
||||
]
|
||||
},
|
||||
"docker_cli": {
|
||||
"allowed_versions": [
|
||||
"26.1.3"
|
||||
]
|
||||
},
|
||||
"docker_engine": {
|
||||
"allowed_versions": [
|
||||
"26.1.3"
|
||||
]
|
||||
},
|
||||
"syft": {
|
||||
"allowed_versions": [
|
||||
"1.52.0"
|
||||
]
|
||||
},
|
||||
"trivy": {
|
||||
"allowed_versions": [
|
||||
"0.74.0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"images": [
|
||||
{
|
||||
"base_image": {
|
||||
"build_arg": "NODE_BASE_IMAGE",
|
||||
"content_digest": "sha256:b64da1de5a51067ab8e75f0bc8dbd0905d8894baa22261f439a4572f41291e50",
|
||||
"source_reference": "node:22-alpine@sha256:b64da1de5a51067ab8e75f0bc8dbd0905d8894baa22261f439a4572f41291e50",
|
||||
"transport": "domestic_registry_mirror",
|
||||
"transport_reference": "docker.m.daocloud.io/library/node:22-alpine@sha256:b64da1de5a51067ab8e75f0bc8dbd0905d8894baa22261f439a4572f41291e50"
|
||||
},
|
||||
"dockerfile": "yuqei-infra/docker/contract-web.Dockerfile",
|
||||
"environment_key": "CONTRACT_WEB_IMAGE",
|
||||
"name": "yuqei/contract-web",
|
||||
"role": "contract-web"
|
||||
},
|
||||
{
|
||||
"base_image": {
|
||||
"build_arg": "PYTHON_BASE_IMAGE",
|
||||
"content_digest": "sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b",
|
||||
"source_reference": "python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b",
|
||||
"transport": "domestic_registry_mirror",
|
||||
"transport_reference": "docker.m.daocloud.io/library/python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b"
|
||||
},
|
||||
"dockerfile": "yuqei-infra/docker/contract-api.Dockerfile",
|
||||
"environment_key": "CONTRACT_API_IMAGE",
|
||||
"name": "yuqei/contract-api",
|
||||
"role": "contract-api"
|
||||
},
|
||||
{
|
||||
"base_image": {
|
||||
"build_arg": "PYTHON_BASE_IMAGE",
|
||||
"content_digest": "sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b",
|
||||
"source_reference": "python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b",
|
||||
"transport": "domestic_registry_mirror",
|
||||
"transport_reference": "docker.m.daocloud.io/library/python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b"
|
||||
},
|
||||
"dockerfile": "yuqei-infra/docker/ai-platform-api.Dockerfile",
|
||||
"environment_key": "AI_PLATFORM_API_IMAGE",
|
||||
"name": "yuqei/ai-platform-api",
|
||||
"role": "ai-platform-api"
|
||||
},
|
||||
{
|
||||
"base_image": {
|
||||
"build_arg": "PYTHON_BASE_IMAGE",
|
||||
"content_digest": "sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b",
|
||||
"source_reference": "python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b",
|
||||
"transport": "domestic_registry_mirror",
|
||||
"transport_reference": "docker.m.daocloud.io/library/python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b"
|
||||
},
|
||||
"dockerfile": "yuqei-infra/docker/worker.Dockerfile",
|
||||
"environment_key": "AI_WORKER_IMAGE",
|
||||
"name": "yuqei/ai-worker",
|
||||
"role": "ai-worker"
|
||||
}
|
||||
],
|
||||
"sbom": {
|
||||
"format": "spdx-json",
|
||||
"generator": "syft",
|
||||
"spdx_version": "SPDX-2.3"
|
||||
},
|
||||
"vulnerability_scan": {
|
||||
"database": {
|
||||
"fail_closed": true,
|
||||
"maximum_age_hours": 24,
|
||||
"repository_transport": {
|
||||
"allowed_media_types": [
|
||||
"application/vnd.oci.image.manifest.v1+json"
|
||||
],
|
||||
"reference": "docker.m.daocloud.io/aquasec/trivy-db:2",
|
||||
"schema_version": 2,
|
||||
"transport": "domestic_registry_mirror"
|
||||
},
|
||||
"require_next_update_after_scan": true
|
||||
},
|
||||
"exemptions": [
|
||||
{
|
||||
"id": "RC04.4-DEBIAN13-PYTHON-UNFIXED-HIGH-001",
|
||||
"expires_at": "2026-10-20T00:00:00Z",
|
||||
"scope": {
|
||||
"roles": [
|
||||
"contract-api",
|
||||
"ai-platform-api",
|
||||
"ai-worker"
|
||||
],
|
||||
"class": "os-pkgs",
|
||||
"type": "debian",
|
||||
"os_family": "debian",
|
||||
"severity": "HIGH",
|
||||
"fixed_version_must_be_empty": true
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"vulnerability_id": "CVE-2025-69720",
|
||||
"package_names": [
|
||||
"libncursesw6",
|
||||
"libtinfo6",
|
||||
"ncurses-base",
|
||||
"ncurses-bin"
|
||||
],
|
||||
"installed_version": "6.5+20250216-2"
|
||||
},
|
||||
{
|
||||
"vulnerability_id": "CVE-2026-16742",
|
||||
"package_names": [
|
||||
"libsystemd0",
|
||||
"libudev1"
|
||||
],
|
||||
"installed_version": "257.13-1~deb13u1"
|
||||
},
|
||||
{
|
||||
"vulnerability_id": "CVE-2026-54369",
|
||||
"package_names": [
|
||||
"libacl1"
|
||||
],
|
||||
"installed_version": "2.3.2-2+b1"
|
||||
},
|
||||
{
|
||||
"vulnerability_id": "CVE-2026-76642",
|
||||
"package_names": [
|
||||
"bsdutils",
|
||||
"libblkid1",
|
||||
"liblastlog2-2",
|
||||
"libmount1",
|
||||
"libsmartcols1",
|
||||
"libuuid1",
|
||||
"login",
|
||||
"mount",
|
||||
"util-linux"
|
||||
],
|
||||
"installed_version": "1:2.41.5-0+deb13u1"
|
||||
},
|
||||
{
|
||||
"vulnerability_id": "CVE-2026-78408",
|
||||
"package_names": [
|
||||
"bsdutils",
|
||||
"libblkid1",
|
||||
"liblastlog2-2",
|
||||
"libmount1",
|
||||
"libsmartcols1",
|
||||
"libuuid1",
|
||||
"login",
|
||||
"mount",
|
||||
"util-linux"
|
||||
],
|
||||
"installed_version": "1:2.41.5-0+deb13u1"
|
||||
},
|
||||
{
|
||||
"vulnerability_id": "CVE-2026-78409",
|
||||
"package_names": [
|
||||
"bsdutils",
|
||||
"libblkid1",
|
||||
"liblastlog2-2",
|
||||
"libmount1",
|
||||
"libsmartcols1",
|
||||
"libuuid1",
|
||||
"login",
|
||||
"mount",
|
||||
"util-linux"
|
||||
],
|
||||
"installed_version": "1:2.41.5-0+deb13u1"
|
||||
},
|
||||
{
|
||||
"vulnerability_id": "CVE-2026-78410",
|
||||
"package_names": [
|
||||
"bsdutils",
|
||||
"libblkid1",
|
||||
"liblastlog2-2",
|
||||
"libmount1",
|
||||
"libsmartcols1",
|
||||
"libuuid1",
|
||||
"login",
|
||||
"mount",
|
||||
"util-linux"
|
||||
],
|
||||
"installed_version": "1:2.41.5-0+deb13u1"
|
||||
},
|
||||
{
|
||||
"vulnerability_id": "CVE-2026-9538",
|
||||
"package_names": [
|
||||
"perl-base"
|
||||
],
|
||||
"installed_version": "5.40.1-6+deb13u1"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"fail_closed_on": [
|
||||
"database_download_failure",
|
||||
"database_metadata_missing",
|
||||
"database_expired",
|
||||
"database_too_old",
|
||||
"scanner_execution_failure",
|
||||
"scan_output_missing",
|
||||
"json_parse_failure",
|
||||
"schema_validation_failure",
|
||||
"waiver_expired",
|
||||
"waiver_scope_mismatch",
|
||||
"waiver_finding_mismatch"
|
||||
],
|
||||
"format": "json",
|
||||
"ignore_unfixed": false,
|
||||
"scanner": "trivy",
|
||||
"scanners": [
|
||||
"vuln"
|
||||
],
|
||||
"thresholds": {
|
||||
"CRITICAL": 0,
|
||||
"HIGH": 0
|
||||
}
|
||||
},
|
||||
"unsupported_tools": {
|
||||
"skopeo": "not_required_and_must_not_be_used_by_this_pipeline"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a Linux build bundle and emit its canonical installable manifest.
|
||||
|
||||
The assembler copies nothing and trusts no receipt claim without re-reading the
|
||||
referenced bytes. It refuses missing/extra roles, duplicate identities or
|
||||
paths, digest drift, traversal, symlinks, stale scanner metadata, and any
|
||||
non-passing vulnerability report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
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
|
||||
COMMAND_TIMEOUTS_SECONDS,
|
||||
COMPOSE_GATE_POLICY,
|
||||
CONTROLLED_SUBPROCESS_ENV,
|
||||
INSTALLABLE_MANIFEST_SCHEMA,
|
||||
LOCAL_DOCKER_HOST,
|
||||
PINNED_EXECUTABLES,
|
||||
REQUIRED_IMAGE_SPECS,
|
||||
REQUIRED_ROLES,
|
||||
REQUIRED_TOOL_VERSIONS,
|
||||
TARGET,
|
||||
TRIVY_DB_REPOSITORY_TRANSPORT,
|
||||
ReleaseValidationError,
|
||||
canonical_json_bytes,
|
||||
ensure_regular_file_under,
|
||||
file_record,
|
||||
inspect_docker_archive,
|
||||
inspect_oci_archive,
|
||||
load_json_file,
|
||||
load_policy,
|
||||
load_source_manifest,
|
||||
parse_rfc3339,
|
||||
validate_receipt_payload_digest,
|
||||
validate_compose_gate_report,
|
||||
validate_candidate_checkout,
|
||||
validate_manifest_commit_tree,
|
||||
validate_relative_path,
|
||||
validate_spdx,
|
||||
validate_tag_binding,
|
||||
validate_trivy_db_metadata,
|
||||
validate_trivy_scan,
|
||||
verify_file_record,
|
||||
verify_source_archive,
|
||||
synthetic_compose_gate_env_bytes,
|
||||
with_payload_digest,
|
||||
)
|
||||
|
||||
|
||||
RELEASE_CONTROL_PATHS = {
|
||||
"runner": "yuqei-infra/scripts/run-linux-release-build.py",
|
||||
"common": "yuqei-infra/scripts/linux_release_common.py",
|
||||
"policy": "yuqei-infra/release/linux-release-security-policy.v1.json",
|
||||
}
|
||||
|
||||
|
||||
def _mapping(value: Any, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise ReleaseValidationError(f"{label} must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _list(value: Any, label: str) -> list[Any]:
|
||||
if not isinstance(value, list):
|
||||
raise ReleaseValidationError(f"{label} must be a JSON array")
|
||||
return value
|
||||
|
||||
|
||||
def _record_path(record: Any, label: str) -> str:
|
||||
value = _mapping(record, label).get("path")
|
||||
if not isinstance(value, str):
|
||||
raise ReleaseValidationError(f"{label}.path must be a string")
|
||||
return value
|
||||
|
||||
|
||||
def _compare_summary(record: dict[str, Any], summary: dict[str, Any], *, label: str) -> None:
|
||||
drift = {
|
||||
key: {"receipt": record.get(key), "actual": value}
|
||||
for key, value in summary.items()
|
||||
if record.get(key) != value
|
||||
}
|
||||
if drift:
|
||||
raise ReleaseValidationError(f"{label} parsed metadata drift: {json.dumps(drift, sort_keys=True)}")
|
||||
|
||||
|
||||
def _path_relative_to_root(root: Path, path: Path, *, label: str) -> str:
|
||||
try:
|
||||
relative = path.resolve(strict=True).relative_to(root.resolve(strict=True))
|
||||
except (OSError, ValueError) as error:
|
||||
raise ReleaseValidationError(f"{label} must be within the artifact root") from error
|
||||
result = relative.as_posix()
|
||||
ensure_regular_file_under(root, result)
|
||||
return result
|
||||
|
||||
|
||||
def _reject_unlisted_entries(root: Path, expected_files: set[str], output: Path) -> None:
|
||||
output_relative = None
|
||||
try:
|
||||
output_relative = output.resolve(strict=False).relative_to(root.resolve(strict=True)).as_posix()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
actual_files: set[str] = set()
|
||||
for path in root.rglob("*"):
|
||||
relative = path.relative_to(root).as_posix()
|
||||
if path.is_symlink():
|
||||
raise ReleaseValidationError(f"installable artifact tree contains a symlink: {relative}")
|
||||
if path.is_file():
|
||||
actual_files.add(relative)
|
||||
elif not path.is_dir():
|
||||
raise ReleaseValidationError(f"installable artifact tree contains a special entry: {relative}")
|
||||
if output_relative:
|
||||
actual_files.discard(output_relative)
|
||||
if actual_files != expected_files:
|
||||
raise ReleaseValidationError(
|
||||
"artifact tree differs from the receipt: "
|
||||
f"missing={sorted(expected_files - actual_files)}, unlisted={sorted(actual_files - expected_files)}"
|
||||
)
|
||||
|
||||
|
||||
def _write_exclusive_manifest(output: Path, payload: dict[str, Any]) -> None:
|
||||
"""Durably create an owner-only manifest without following or replacing paths."""
|
||||
|
||||
parent = output.parent
|
||||
if parent.is_symlink() or not parent.is_dir():
|
||||
raise ReleaseValidationError(
|
||||
"installable manifest parent must be a real directory"
|
||||
)
|
||||
if os.name != "nt":
|
||||
parent_metadata = parent.stat()
|
||||
if (
|
||||
parent_metadata.st_uid != os.geteuid()
|
||||
or stat.S_IMODE(parent_metadata.st_mode) & 0o022
|
||||
):
|
||||
raise ReleaseValidationError(
|
||||
"installable manifest parent must be owned by the executor and not group/world writable"
|
||||
)
|
||||
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = os.open(output, flags, 0o600)
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1:
|
||||
raise ReleaseValidationError(
|
||||
"installable manifest output must be a single-link regular file"
|
||||
)
|
||||
data = canonical_json_bytes(payload)
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
written = os.write(descriptor, view)
|
||||
if written <= 0:
|
||||
raise OSError("short write while creating installable manifest")
|
||||
view = view[written:]
|
||||
if os.name != "nt":
|
||||
os.fchmod(descriptor, 0o600)
|
||||
os.fsync(descriptor)
|
||||
final = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(final.st_mode) or final.st_nlink != 1:
|
||||
raise ReleaseValidationError(
|
||||
"installable manifest output changed identity while being written"
|
||||
)
|
||||
try:
|
||||
path_identity = os.lstat(output)
|
||||
except OSError as error:
|
||||
raise ReleaseValidationError(
|
||||
"installable manifest output path disappeared while being written"
|
||||
) from error
|
||||
if (
|
||||
not stat.S_ISREG(path_identity.st_mode)
|
||||
or path_identity.st_nlink != 1
|
||||
or path_identity.st_dev != final.st_dev
|
||||
or path_identity.st_ino != final.st_ino
|
||||
):
|
||||
raise ReleaseValidationError(
|
||||
"installable manifest output path changed identity while being written"
|
||||
)
|
||||
except FileExistsError as error:
|
||||
raise ReleaseValidationError(
|
||||
"installable manifest output must not already exist"
|
||||
) from error
|
||||
except OSError as error:
|
||||
if error.errno == errno.ELOOP:
|
||||
raise ReleaseValidationError(
|
||||
"installable manifest output must not be a symbolic link"
|
||||
) from error
|
||||
raise
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
if os.name != "nt":
|
||||
directory_descriptor = os.open(
|
||||
output.parent,
|
||||
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
|
||||
)
|
||||
try:
|
||||
os.fsync(directory_descriptor)
|
||||
finally:
|
||||
os.close(directory_descriptor)
|
||||
|
||||
|
||||
def assemble_installable_release(
|
||||
*,
|
||||
artifact_root: Path,
|
||||
receipt_path: Path,
|
||||
expected_receipt_sha256: str,
|
||||
repo: Path,
|
||||
output: Path,
|
||||
write_output: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if artifact_root.is_symlink() or not artifact_root.is_dir():
|
||||
raise ReleaseValidationError("artifact root must be a real directory")
|
||||
receipt_relative = _path_relative_to_root(artifact_root, receipt_path, label="build receipt")
|
||||
receipt_bytes = receipt_path.read_bytes()
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", expected_receipt_sha256):
|
||||
raise ReleaseValidationError("expected build receipt raw SHA-256 is invalid")
|
||||
if hashlib.sha256(receipt_bytes).hexdigest() != expected_receipt_sha256:
|
||||
raise ReleaseValidationError(
|
||||
"build receipt does not match the independently supplied raw SHA-256"
|
||||
)
|
||||
receipt = validate_receipt_payload_digest(load_json_file(receipt_path, label="build receipt"))
|
||||
if canonical_json_bytes(receipt) != receipt_bytes:
|
||||
raise ReleaseValidationError("build receipt is not serialized as canonical JSON")
|
||||
|
||||
policy_record = _mapping(receipt.get("policy"), "receipt policy")
|
||||
policy_path = verify_file_record(artifact_root, policy_record, label="receipt policy")
|
||||
policy = load_policy(policy_path)
|
||||
if policy_record.get("schema_version") != policy["schema_version"] or policy_record.get("target") != TARGET:
|
||||
raise ReleaseValidationError("receipt policy metadata drift")
|
||||
|
||||
candidate = _mapping(receipt.get("candidate"), "receipt candidate")
|
||||
required_candidate = {
|
||||
"commit",
|
||||
"tag",
|
||||
"tag_object_oid",
|
||||
"tree",
|
||||
"commit_tree",
|
||||
"commit_timestamp",
|
||||
"source_manifest_payload_sha256",
|
||||
"source_manifest",
|
||||
"source_archive",
|
||||
}
|
||||
if set(candidate) != required_candidate:
|
||||
raise ReleaseValidationError("receipt candidate fields are missing or unknown")
|
||||
source_manifest_path = verify_file_record(
|
||||
artifact_root, candidate["source_manifest"], label="source manifest"
|
||||
)
|
||||
source_manifest = load_source_manifest(source_manifest_path, commit=candidate["commit"])
|
||||
if source_manifest["manifest_payload_sha256"] != candidate["source_manifest_payload_sha256"]:
|
||||
raise ReleaseValidationError("source manifest payload digest drift in build receipt")
|
||||
if source_manifest["candidate"]["tree"] != candidate["tree"]:
|
||||
raise ReleaseValidationError("source manifest tree drift in build receipt")
|
||||
if source_manifest["candidate"]["commit_timestamp"] != candidate["commit_timestamp"]:
|
||||
raise ReleaseValidationError("source manifest timestamp drift in build receipt")
|
||||
source_archive_path = verify_file_record(
|
||||
artifact_root, candidate["source_archive"], label="source archive"
|
||||
)
|
||||
archive_summary = verify_source_archive(source_archive_path, source_manifest)
|
||||
for key in ("root_prefix", "file_count"):
|
||||
if candidate["source_archive"].get(key) != archive_summary[key]:
|
||||
raise ReleaseValidationError(f"source archive {key} drift in build receipt")
|
||||
commit_tree = validate_manifest_commit_tree(
|
||||
repo, commit=candidate["commit"], manifest=source_manifest
|
||||
)
|
||||
if candidate["commit_tree"] != commit_tree:
|
||||
raise ReleaseValidationError("candidate commit-tree evidence drift in build receipt")
|
||||
tag_binding = validate_tag_binding(
|
||||
repo,
|
||||
tag=candidate["tag"],
|
||||
commit=candidate["commit"],
|
||||
expected_tree=candidate["tree"],
|
||||
)
|
||||
if candidate["tag_object_oid"] != tag_binding["tag_object_oid"]:
|
||||
raise ReleaseValidationError("annotated tag object drift in build receipt")
|
||||
|
||||
builder = _mapping(receipt.get("builder"), "receipt builder")
|
||||
required_builder_fields = {
|
||||
"os",
|
||||
"architecture",
|
||||
"target_platform",
|
||||
"tools",
|
||||
"executables",
|
||||
"controlled_environment",
|
||||
"docker_host",
|
||||
"command_timeouts_seconds",
|
||||
"buildx_builder",
|
||||
"base_image_transports",
|
||||
"candidate_checkout",
|
||||
"release_controls",
|
||||
"skopeo_used",
|
||||
}
|
||||
if set(builder) != required_builder_fields:
|
||||
raise ReleaseValidationError("receipt builder fields are missing or unknown")
|
||||
if builder.get("os") != "linux" or builder.get("architecture") != "amd64":
|
||||
raise ReleaseValidationError("build receipt was not produced by a Linux amd64 builder")
|
||||
if builder.get("target_platform") != TARGET["platform"]:
|
||||
raise ReleaseValidationError("build receipt target platform drift")
|
||||
if builder.get("tools") != REQUIRED_TOOL_VERSIONS:
|
||||
raise ReleaseValidationError("build receipt tool versions are not the pinned toolchain")
|
||||
if builder.get("command_timeouts_seconds") != COMMAND_TIMEOUTS_SECONDS:
|
||||
raise ReleaseValidationError("build receipt external-command timeout contract drift")
|
||||
if builder.get("controlled_environment") != CONTROLLED_SUBPROCESS_ENV:
|
||||
raise ReleaseValidationError("build receipt controlled subprocess environment drift")
|
||||
if builder.get("docker_host") != LOCAL_DOCKER_HOST:
|
||||
raise ReleaseValidationError("build receipt Docker daemon endpoint drift")
|
||||
candidate_checkout = _mapping(
|
||||
builder.get("candidate_checkout"), "receipt candidate checkout"
|
||||
)
|
||||
if set(candidate_checkout) != {
|
||||
"head",
|
||||
"worktree_clean",
|
||||
"replace_refs_present",
|
||||
"status_sha256",
|
||||
}:
|
||||
raise ReleaseValidationError(
|
||||
"receipt candidate checkout fields are missing or unknown"
|
||||
)
|
||||
actual_checkout = validate_candidate_checkout(repo, commit=candidate["commit"])
|
||||
if candidate_checkout != actual_checkout:
|
||||
raise ReleaseValidationError("receipt candidate checkout evidence drift")
|
||||
|
||||
source_records = {
|
||||
item["path"]: item for item in source_manifest["files"]["items"]
|
||||
}
|
||||
release_controls = _mapping(
|
||||
builder.get("release_controls"), "receipt release controls"
|
||||
)
|
||||
if set(release_controls) != set(RELEASE_CONTROL_PATHS):
|
||||
raise ReleaseValidationError("receipt release control inventory drift")
|
||||
for name, relative in RELEASE_CONTROL_PATHS.items():
|
||||
record = _mapping(release_controls[name], f"receipt release control {name}")
|
||||
if set(record) != {"path", "size_bytes", "sha256", "git_blob_oid"}:
|
||||
raise ReleaseValidationError(
|
||||
f"receipt release control fields are missing or unknown for {name}"
|
||||
)
|
||||
source_record = source_records.get(relative)
|
||||
expected_record = (
|
||||
{
|
||||
"path": relative,
|
||||
"size_bytes": source_record["size"],
|
||||
"sha256": source_record["sha256"],
|
||||
"git_blob_oid": source_record["git_blob_oid"],
|
||||
}
|
||||
if isinstance(source_record, dict)
|
||||
else None
|
||||
)
|
||||
if record != expected_record:
|
||||
raise ReleaseValidationError(
|
||||
f"receipt release control source binding drift for {name}"
|
||||
)
|
||||
executable_records = _mapping(builder.get("executables"), "receipt executables")
|
||||
if set(executable_records) != set(PINNED_EXECUTABLES):
|
||||
raise ReleaseValidationError("build receipt pinned executable inventory drift")
|
||||
for name, expected_path in PINNED_EXECUTABLES.items():
|
||||
executable = _mapping(executable_records[name], f"receipt executable {name}")
|
||||
if (
|
||||
executable.get("path") != expected_path
|
||||
or executable.get("owner_uid") != 0
|
||||
or executable.get("mode") != "0755"
|
||||
or not isinstance(executable.get("size_bytes"), int)
|
||||
or isinstance(executable.get("size_bytes"), bool)
|
||||
or executable["size_bytes"] <= 0
|
||||
or not isinstance(executable.get("sha256"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", executable["sha256"])
|
||||
):
|
||||
raise ReleaseValidationError(f"build receipt executable evidence drift for {name}")
|
||||
buildx_builder = _mapping(builder.get("buildx_builder"), "receipt Buildx builder")
|
||||
if (
|
||||
buildx_builder.get("driver") not in policy["builder"]["allowed_drivers"]
|
||||
or buildx_builder.get("node_status") != "running"
|
||||
or buildx_builder.get("exporters") != policy["builder"]["required_exporters"]
|
||||
or len(_list(buildx_builder.get("platforms"), "Buildx builder platforms")) == 0
|
||||
or policy["builder"]["required_platform"] not in buildx_builder["platforms"]
|
||||
):
|
||||
raise ReleaseValidationError("build receipt does not prove the required Buildx builder capabilities")
|
||||
if buildx_builder.get("buildkit_version") != policy["builder"]["required_buildkit_version"]:
|
||||
raise ReleaseValidationError("build receipt BuildKit version drift")
|
||||
for receipt_key, policy_key in (
|
||||
("driver_image", "required_driver_image"),
|
||||
("driver_image_digest", "required_driver_image_digest"),
|
||||
("local_driver_image_id", "required_local_driver_image_id"),
|
||||
):
|
||||
if buildx_builder.get(receipt_key) != policy["builder"][policy_key]:
|
||||
raise ReleaseValidationError(f"build receipt {receipt_key} drift")
|
||||
if buildx_builder.get("image_transport") != "domestic_registry_mirror":
|
||||
raise ReleaseValidationError("BuildKit driver image transport is not the pinned domestic mirror")
|
||||
config_root = _mapping(buildx_builder.get("config_root"), "receipt Buildx config root")
|
||||
if (
|
||||
config_root.get("path") != CONTROLLED_SUBPROCESS_ENV["BUILDX_CONFIG"]
|
||||
or config_root.get("selected_builder_instance")
|
||||
!= f"instances/{buildx_builder.get('name')}"
|
||||
or config_root.get("root_owned") is not True
|
||||
or config_root.get("group_or_other_writable") is not False
|
||||
or config_root.get("symlinks_allowed") is not False
|
||||
or not isinstance(config_root.get("entry_count"), int)
|
||||
or isinstance(config_root.get("entry_count"), bool)
|
||||
or config_root["entry_count"] < 1
|
||||
or not isinstance(config_root.get("metadata_sha256"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", config_root["metadata_sha256"])
|
||||
):
|
||||
raise ReleaseValidationError("build receipt fixed Buildx config evidence drift")
|
||||
if not isinstance(buildx_builder.get("inspect_sha256"), str) or not re.fullmatch(
|
||||
r"[0-9a-f]{64}", buildx_builder["inspect_sha256"]
|
||||
):
|
||||
raise ReleaseValidationError("build receipt has no Buildx inspection digest")
|
||||
if not isinstance(buildx_builder.get("container_inspect_sha256"), str) or not re.fullmatch(
|
||||
r"[0-9a-f]{64}", buildx_builder["container_inspect_sha256"]
|
||||
):
|
||||
raise ReleaseValidationError("build receipt has no BuildKit container inspection digest")
|
||||
transport_records = _list(
|
||||
builder.get("base_image_transports"), "receipt base-image transports"
|
||||
)
|
||||
expected_transports = {
|
||||
image["base_image"]["transport_reference"]: image["base_image"]["content_digest"]
|
||||
for image in policy["images"]
|
||||
}
|
||||
if len(transport_records) != len(expected_transports):
|
||||
raise ReleaseValidationError("base-image transport evidence is missing or duplicated")
|
||||
observed_transports: set[str] = set()
|
||||
for record in transport_records:
|
||||
item = _mapping(record, "base-image transport record")
|
||||
reference = item.get("transport_reference")
|
||||
if reference in observed_transports or expected_transports.get(reference) != item.get("content_digest"):
|
||||
raise ReleaseValidationError("base-image transport reference or digest drift")
|
||||
observed_transports.add(reference)
|
||||
if (
|
||||
item.get("transport") != "domestic_registry_mirror"
|
||||
or item.get("media_type") != "application/vnd.oci.image.manifest.v1+json"
|
||||
or not isinstance(item.get("inspect_sha256"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", item["inspect_sha256"])
|
||||
):
|
||||
raise ReleaseValidationError("base-image mirror inspection evidence is invalid")
|
||||
if observed_transports != set(expected_transports):
|
||||
raise ReleaseValidationError("base-image transport evidence does not cover the policy")
|
||||
if builder.get("skopeo_used") is not False:
|
||||
raise ReleaseValidationError("Skopeo is outside the supported release pipeline")
|
||||
|
||||
build = _mapping(receipt.get("build"), "receipt build")
|
||||
if build.get("parallelism") != 1 or build.get("image_order") != list(REQUIRED_ROLES):
|
||||
raise ReleaseValidationError("four images were not recorded in the required serial order")
|
||||
started = parse_rfc3339(build.get("started_at"), label="build.started_at")
|
||||
completed = parse_rfc3339(build.get("completed_at"), label="build.completed_at")
|
||||
if completed < started:
|
||||
raise ReleaseValidationError("build completion precedes build start")
|
||||
if build.get("source_date_epoch") != int(
|
||||
parse_rfc3339(candidate["commit_timestamp"], label="candidate.commit_timestamp").timestamp()
|
||||
):
|
||||
raise ReleaseValidationError("SOURCE_DATE_EPOCH is not bound to the candidate commit timestamp")
|
||||
compose_gate = _mapping(build.get("compose_gate"), "receipt production Compose gate")
|
||||
if (
|
||||
set(compose_gate)
|
||||
!= {
|
||||
"synthetic_environment",
|
||||
"production_secrets_used",
|
||||
"execution_engine",
|
||||
"validator",
|
||||
"environment",
|
||||
"report",
|
||||
}
|
||||
or compose_gate.get("synthetic_environment") is not True
|
||||
or compose_gate.get("production_secrets_used") is not False
|
||||
or compose_gate.get("execution_engine")
|
||||
!= "self_bound_release_runner_builtin_v1"
|
||||
):
|
||||
raise ReleaseValidationError(
|
||||
"production Compose gate fields or self-bound execution engine drifted"
|
||||
)
|
||||
compose_validator = _mapping(
|
||||
compose_gate.get("validator"), "receipt production Compose gate validator"
|
||||
)
|
||||
expected_validator = source_records.get(COMPOSE_GATE_POLICY["validator"])
|
||||
if (
|
||||
expected_validator is None
|
||||
or compose_validator.get("source_path") != COMPOSE_GATE_POLICY["validator"]
|
||||
or compose_validator.get("sha256") != expected_validator["sha256"]
|
||||
or compose_validator.get("size_bytes") != expected_validator["size"]
|
||||
):
|
||||
raise ReleaseValidationError("production Compose gate validator source binding drift")
|
||||
compose_env_record = _mapping(
|
||||
compose_gate.get("environment"), "receipt synthetic Compose gate environment"
|
||||
)
|
||||
compose_env_path = verify_file_record(
|
||||
artifact_root,
|
||||
compose_env_record,
|
||||
label="synthetic Compose gate environment",
|
||||
)
|
||||
expected_compose_env = synthetic_compose_gate_env_bytes()
|
||||
if compose_env_path.read_bytes() != expected_compose_env:
|
||||
raise ReleaseValidationError("synthetic Compose gate environment content drift")
|
||||
compose_report_record = _mapping(
|
||||
compose_gate.get("report"), "receipt production Compose gate report"
|
||||
)
|
||||
compose_report_path = verify_file_record(
|
||||
artifact_root,
|
||||
compose_report_record,
|
||||
label="production Compose gate report",
|
||||
)
|
||||
compose_report_bytes = compose_report_path.read_bytes()
|
||||
compose_report_payload = load_json_file(
|
||||
compose_report_path, label="production Compose gate report"
|
||||
)
|
||||
if canonical_json_bytes(compose_report_payload) != compose_report_bytes:
|
||||
raise ReleaseValidationError("production Compose gate report is not canonical JSON")
|
||||
compose_report_summary = validate_compose_gate_report(
|
||||
compose_report_payload,
|
||||
policy,
|
||||
expected_env_sha256=compose_env_record["sha256"],
|
||||
source_file_sha256={path: item["sha256"] for path, item in source_records.items()},
|
||||
)
|
||||
_compare_summary(
|
||||
compose_report_record,
|
||||
compose_report_summary,
|
||||
label="production Compose gate report",
|
||||
)
|
||||
|
||||
security = _mapping(receipt.get("security"), "receipt security")
|
||||
if security.get("passed") is not True or security.get("fail_closed") is not True:
|
||||
raise ReleaseValidationError("build receipt security gate did not pass fail closed")
|
||||
if security.get("thresholds") != policy["vulnerability_scan"]["thresholds"]:
|
||||
raise ReleaseValidationError("receipt vulnerability threshold drift")
|
||||
if security.get("exemptions") != policy["vulnerability_scan"]["exemptions"]:
|
||||
raise ReleaseValidationError("receipt vulnerability waiver policy drift")
|
||||
claimed_vulnerability_counts = _mapping(
|
||||
security.get("vulnerability_counts"), "receipt vulnerability counts"
|
||||
)
|
||||
claimed_waiver_ids = _list(security.get("waiver_ids"), "receipt waiver IDs")
|
||||
database_record = _mapping(security.get("database"), "receipt Trivy database")
|
||||
database_path = verify_file_record(
|
||||
artifact_root, database_record, label="Trivy database metadata"
|
||||
)
|
||||
validated_at = parse_rfc3339(database_record.get("validated_at"), label="database.validated_at")
|
||||
database_summary = validate_trivy_db_metadata(
|
||||
load_json_file(database_path, label="Trivy database metadata"),
|
||||
policy,
|
||||
at=validated_at,
|
||||
)
|
||||
_compare_summary(database_record, database_summary, label="Trivy database")
|
||||
repository_transport = _mapping(
|
||||
database_record.get("repository_transport"),
|
||||
"receipt Trivy database repository transport",
|
||||
)
|
||||
if (
|
||||
repository_transport.get("reference")
|
||||
!= TRIVY_DB_REPOSITORY_TRANSPORT["reference"]
|
||||
or repository_transport.get("transport")
|
||||
!= TRIVY_DB_REPOSITORY_TRANSPORT["transport"]
|
||||
or repository_transport.get("schema_version")
|
||||
!= TRIVY_DB_REPOSITORY_TRANSPORT["schema_version"]
|
||||
or repository_transport.get("media_type")
|
||||
not in TRIVY_DB_REPOSITORY_TRANSPORT["allowed_media_types"]
|
||||
or repository_transport.get("stable_during_download") is not True
|
||||
):
|
||||
raise ReleaseValidationError("Trivy database repository transport evidence drift")
|
||||
resolved_digest = repository_transport.get("resolved_digest")
|
||||
if not isinstance(resolved_digest, str) or not re.fullmatch(
|
||||
r"sha256:[0-9a-f]{64}", resolved_digest
|
||||
):
|
||||
raise ReleaseValidationError("Trivy database repository resolved digest is invalid")
|
||||
for field in (
|
||||
"before_download_inspect_sha256",
|
||||
"after_download_inspect_sha256",
|
||||
):
|
||||
value = repository_transport.get(field)
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value):
|
||||
raise ReleaseValidationError(
|
||||
f"Trivy database repository {field} evidence is invalid"
|
||||
)
|
||||
if not (started <= validated_at <= completed):
|
||||
raise ReleaseValidationError("Trivy database was not validated during the recorded build")
|
||||
|
||||
images = _list(receipt.get("images"), "receipt images")
|
||||
roles = [image.get("role") if isinstance(image, dict) else None for image in images]
|
||||
if len(roles) != len(set(roles)):
|
||||
raise ReleaseValidationError("duplicate image role in build receipt")
|
||||
if roles != list(REQUIRED_ROLES):
|
||||
missing = sorted(set(REQUIRED_ROLES) - set(roles))
|
||||
extra = sorted(str(role) for role in set(roles) - set(REQUIRED_ROLES))
|
||||
raise ReleaseValidationError(f"image roles are incomplete or unordered: missing={missing}, extra={extra}")
|
||||
|
||||
tracked_paths: dict[str, str] = {receipt_relative.casefold(): receipt_relative}
|
||||
expected_files: set[str] = {receipt_relative}
|
||||
manifest_images: list[dict[str, Any]] = []
|
||||
oci_manifest_digests: set[str] = set()
|
||||
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()
|
||||
|
||||
def track(record: dict[str, Any], label: str) -> None:
|
||||
path = _record_path(record, label)
|
||||
validate_relative_path(path, label=f"{label}.path")
|
||||
folded = path.casefold()
|
||||
if folded in tracked_paths:
|
||||
raise ReleaseValidationError(
|
||||
f"duplicate or case-colliding artifact path: {path} and {tracked_paths[folded]}"
|
||||
)
|
||||
tracked_paths[folded] = path
|
||||
expected_files.add(path)
|
||||
|
||||
track(policy_record, "policy")
|
||||
track(_mapping(candidate["source_manifest"], "source manifest"), "source manifest")
|
||||
track(_mapping(candidate["source_archive"], "source archive"), "source archive")
|
||||
track(database_record, "Trivy database metadata")
|
||||
track(compose_env_record, "synthetic Compose gate environment")
|
||||
track(compose_report_record, "production Compose gate report")
|
||||
if compose_env_record["path"] != "validation/synthetic-compose-gate.env":
|
||||
raise ReleaseValidationError("synthetic Compose gate environment path drift")
|
||||
if compose_report_record["path"] != "validation/production-compose-gate.json":
|
||||
raise ReleaseValidationError("production Compose gate report path drift")
|
||||
|
||||
for index, (raw_image, expected) in enumerate(zip(images, REQUIRED_IMAGE_SPECS, strict=True), 1):
|
||||
image = _mapping(raw_image, f"receipt image {index}")
|
||||
if image.get("sequence") != index:
|
||||
raise ReleaseValidationError(f"image sequence drift for {expected['role']}")
|
||||
if image.get("role") != expected["role"] or image.get("image_name") != expected["name"]:
|
||||
raise ReleaseValidationError(f"image identity drift for sequence {index}")
|
||||
if image.get("environment_key") != expected["environment_key"]:
|
||||
raise ReleaseValidationError(f"deployment environment key drift for {expected['role']}")
|
||||
if image.get("dockerfile") != expected["dockerfile"]:
|
||||
raise ReleaseValidationError(f"Dockerfile drift for {expected['role']}")
|
||||
if image.get("base_image") != expected["base_image"]:
|
||||
raise ReleaseValidationError(f"base-image transport/digest drift for {expected['role']}")
|
||||
if image.get("platform") != TARGET["platform"]:
|
||||
raise ReleaseValidationError(f"platform drift for {expected['role']}")
|
||||
expected_ref = f"{expected['name']}:{candidate['commit']}"
|
||||
if image.get("image_ref") != expected_ref:
|
||||
raise ReleaseValidationError(f"image reference is not commit-bound for {expected['role']}")
|
||||
|
||||
oci_record = _mapping(image.get("oci"), f"{expected['role']} OCI record")
|
||||
docker_record = _mapping(
|
||||
image.get("docker_archive"), f"{expected['role']} Docker archive record"
|
||||
)
|
||||
sbom_record = _mapping(image.get("sbom"), f"{expected['role']} SBOM record")
|
||||
scan_record = _mapping(image.get("scan"), f"{expected['role']} scan record")
|
||||
for record, kind, required_prefix in (
|
||||
(oci_record, "OCI", "images/"),
|
||||
(docker_record, "Docker archive", "images-docker/"),
|
||||
(sbom_record, "SBOM", "sbom/"),
|
||||
(scan_record, "scan", "scans/"),
|
||||
):
|
||||
track(record, f"{expected['role']} {kind}")
|
||||
if not _record_path(record, f"{expected['role']} {kind}").startswith(required_prefix):
|
||||
raise ReleaseValidationError(f"{expected['role']} {kind} path is outside {required_prefix}")
|
||||
|
||||
oci_path = verify_file_record(artifact_root, oci_record, label=f"{expected['role']} OCI")
|
||||
oci_summary = inspect_oci_archive(oci_path)
|
||||
_compare_summary(oci_record, oci_summary, label=f"{expected['role']} OCI")
|
||||
if oci_summary["revision"] != candidate["commit"] or oci_summary["version"] != candidate["tag"]:
|
||||
raise ReleaseValidationError(f"OCI candidate labels drift for {expected['role']}")
|
||||
if (
|
||||
oci_summary["release_role"] != expected["role"]
|
||||
or oci_summary["title"] != expected["name"]
|
||||
):
|
||||
raise ReleaseValidationError(f"OCI role labels drift for {expected['role']}")
|
||||
manifest_digest = oci_summary["manifest_digest"]
|
||||
if manifest_digest in oci_manifest_digests:
|
||||
raise ReleaseValidationError(f"duplicate OCI manifest digest across image roles: {manifest_digest}")
|
||||
oci_manifest_digests.add(manifest_digest)
|
||||
|
||||
docker_path = verify_file_record(
|
||||
artifact_root, docker_record, label=f"{expected['role']} Docker archive"
|
||||
)
|
||||
docker_summary = inspect_docker_archive(
|
||||
docker_path, expected_repo_tag=expected_ref
|
||||
)
|
||||
_compare_summary(
|
||||
docker_record, docker_summary, label=f"{expected['role']} Docker archive"
|
||||
)
|
||||
if docker_summary["config_digest"] != oci_summary["config_digest"]:
|
||||
raise ReleaseValidationError(
|
||||
f"OCI/Docker archive image identity drift for {expected['role']}"
|
||||
)
|
||||
if (
|
||||
docker_summary["revision"] != candidate["commit"]
|
||||
or docker_summary["version"] != candidate["tag"]
|
||||
or docker_summary["release_role"] != expected["role"]
|
||||
or docker_summary["title"] != expected["name"]
|
||||
):
|
||||
raise ReleaseValidationError(f"Docker archive role labels drift for {expected['role']}")
|
||||
|
||||
sbom_path = verify_file_record(artifact_root, sbom_record, label=f"{expected['role']} SBOM")
|
||||
sbom_summary = validate_spdx(load_json_file(sbom_path, label=f"{expected['role']} SBOM"), policy)
|
||||
_compare_summary(sbom_record, sbom_summary, label=f"{expected['role']} SBOM")
|
||||
|
||||
scan_path = verify_file_record(artifact_root, scan_record, label=f"{expected['role']} scan")
|
||||
scan_evaluated_at = parse_rfc3339(
|
||||
scan_record.get("waiver_evaluated_at"),
|
||||
label=f"{expected['role']} scan waiver_evaluated_at",
|
||||
)
|
||||
if not (started <= scan_evaluated_at <= completed):
|
||||
raise ReleaseValidationError(
|
||||
f"{expected['role']} vulnerability waiver was not evaluated during the build"
|
||||
)
|
||||
scan_summary = validate_trivy_scan(
|
||||
load_json_file(scan_path, label=f"{expected['role']} Trivy scan"),
|
||||
policy,
|
||||
expected_config_digest=oci_summary["config_digest"],
|
||||
expected_artifact_basename=docker_path.name,
|
||||
role=expected["role"],
|
||||
at=scan_evaluated_at,
|
||||
)
|
||||
_compare_summary(scan_record, scan_summary, label=f"{expected['role']} scan")
|
||||
if scan_record.get("scanner") != "trivy" or scan_record.get("scanner_version") != REQUIRED_TOOL_VERSIONS["trivy"]:
|
||||
raise ReleaseValidationError(f"scanner identity drift for {expected['role']}")
|
||||
if scan_record.get("passed") is not True:
|
||||
raise ReleaseValidationError(f"scan is not passing for {expected['role']}")
|
||||
for bucket in aggregate_vulnerability_counts:
|
||||
for severity in aggregate_vulnerability_counts[bucket]:
|
||||
aggregate_vulnerability_counts[bucket][severity] += scan_summary["counts"][
|
||||
bucket
|
||||
][severity]
|
||||
applied_waiver_ids.update(scan_summary["waiver_ids"])
|
||||
manifest_images.append(
|
||||
{
|
||||
"role": expected["role"],
|
||||
"image_name": expected["name"],
|
||||
"environment_key": expected["environment_key"],
|
||||
"image_ref": expected_ref,
|
||||
"platform": TARGET["platform"],
|
||||
"manifest_digest": manifest_digest,
|
||||
"config_digest": oci_summary["config_digest"],
|
||||
"registry_reference": f"{expected['name']}@{manifest_digest}",
|
||||
"offline_image_id": oci_summary["config_digest"],
|
||||
"oci": {key: oci_record[key] for key in ("path", "sha256", "size_bytes")},
|
||||
"docker_archive": {
|
||||
key: docker_record[key] for key in ("path", "sha256", "size_bytes")
|
||||
},
|
||||
"sbom": {key: sbom_record[key] for key in ("path", "sha256", "size_bytes")},
|
||||
"scan": {key: scan_record[key] for key in ("path", "sha256", "size_bytes")},
|
||||
}
|
||||
)
|
||||
|
||||
if claimed_vulnerability_counts != aggregate_vulnerability_counts:
|
||||
raise ReleaseValidationError("receipt aggregate vulnerability counts drift")
|
||||
if claimed_waiver_ids != sorted(applied_waiver_ids):
|
||||
raise ReleaseValidationError("receipt applied waiver ID inventory drift")
|
||||
|
||||
if output.parent.resolve(strict=True) != artifact_root.resolve(strict=True):
|
||||
raise ReleaseValidationError("installable manifest must be written directly in the artifact root")
|
||||
_reject_unlisted_entries(artifact_root, expected_files, output)
|
||||
|
||||
indexed_records = [
|
||||
{
|
||||
"path": path,
|
||||
"sha256": sha_record["sha256"],
|
||||
"size_bytes": sha_record["size_bytes"],
|
||||
}
|
||||
for sha_record in (
|
||||
policy_record,
|
||||
candidate["source_manifest"],
|
||||
candidate["source_archive"],
|
||||
database_record,
|
||||
compose_env_record,
|
||||
compose_report_record,
|
||||
*(
|
||||
artifact
|
||||
for image in images
|
||||
for artifact in (
|
||||
image["oci"],
|
||||
image["docker_archive"],
|
||||
image["sbom"],
|
||||
image["scan"],
|
||||
)
|
||||
),
|
||||
)
|
||||
for path in [sha_record["path"]]
|
||||
]
|
||||
indexed_records.append(file_record(receipt_path, receipt_relative))
|
||||
indexed_records.sort(key=lambda item: item["path"].encode("utf-8"))
|
||||
installable_without_digest = {
|
||||
"schema_version": INSTALLABLE_MANIFEST_SCHEMA,
|
||||
"candidate": {
|
||||
"commit": candidate["commit"],
|
||||
"tag": candidate["tag"],
|
||||
"tag_object_oid": candidate["tag_object_oid"],
|
||||
"tree": candidate["tree"],
|
||||
"commit_tree": candidate["commit_tree"],
|
||||
"source_manifest_payload_sha256": candidate["source_manifest_payload_sha256"],
|
||||
"checkout": candidate_checkout,
|
||||
},
|
||||
"target": TARGET,
|
||||
"policy": {
|
||||
"path": policy_record["path"],
|
||||
"schema_version": policy["schema_version"],
|
||||
"sha256": policy_record["sha256"],
|
||||
"size_bytes": policy_record["size_bytes"],
|
||||
"target": TARGET,
|
||||
},
|
||||
"build_receipt": {
|
||||
**file_record(receipt_path, receipt_relative),
|
||||
"payload_sha256": receipt["receipt_payload_sha256"],
|
||||
},
|
||||
"security_gate": {
|
||||
"passed": True,
|
||||
"fail_closed": True,
|
||||
"thresholds": policy["vulnerability_scan"]["thresholds"],
|
||||
"exemptions": policy["vulnerability_scan"]["exemptions"],
|
||||
"vulnerability_counts": aggregate_vulnerability_counts,
|
||||
"waiver_ids": sorted(applied_waiver_ids),
|
||||
"trivy_database": {
|
||||
**database_summary,
|
||||
"repository_transport": repository_transport,
|
||||
},
|
||||
},
|
||||
"execution_contract": {
|
||||
"command_timeouts_seconds": COMMAND_TIMEOUTS_SECONDS,
|
||||
"controlled_environment": CONTROLLED_SUBPROCESS_ENV,
|
||||
"docker_host": LOCAL_DOCKER_HOST,
|
||||
"pinned_executables": PINNED_EXECUTABLES,
|
||||
"release_controls": release_controls,
|
||||
},
|
||||
"compose_gate": {
|
||||
"passed": True,
|
||||
"synthetic_environment": True,
|
||||
"production_secrets_used": False,
|
||||
"execution_engine": "self_bound_release_runner_builtin_v1",
|
||||
"validator": compose_validator,
|
||||
"environment": {
|
||||
key: compose_env_record[key] for key in ("path", "sha256", "size_bytes")
|
||||
},
|
||||
"report": {
|
||||
key: compose_report_record[key]
|
||||
for key in ("path", "sha256", "size_bytes")
|
||||
},
|
||||
"case_count": compose_report_summary["case_count"],
|
||||
"compose_inputs": compose_report_summary["compose_inputs"],
|
||||
},
|
||||
"images": manifest_images,
|
||||
"docker_import_plan": {
|
||||
"schema_version": "yuqei.docker-image-import-plan.v1",
|
||||
"implicit_preloaded_images_allowed": False,
|
||||
"images": [
|
||||
{
|
||||
"sequence": index,
|
||||
"role": image["role"],
|
||||
"environment_key": image["environment_key"],
|
||||
"archive": image["docker_archive"],
|
||||
"command_argv": [
|
||||
PINNED_EXECUTABLES["docker"],
|
||||
"--host",
|
||||
LOCAL_DOCKER_HOST,
|
||||
"image",
|
||||
"load",
|
||||
"--input",
|
||||
image["docker_archive"]["path"],
|
||||
],
|
||||
"expected_repo_tag": image["image_ref"],
|
||||
"expected_loaded_image_id": image["config_digest"],
|
||||
"expected_oci_manifest_digest": image["manifest_digest"],
|
||||
"offline_compose_override": image["offline_image_id"],
|
||||
"registry_reference_for_online_release": image["registry_reference"],
|
||||
"repo_digests_expected_after_load": [],
|
||||
}
|
||||
for index, image in enumerate(manifest_images, 1)
|
||||
],
|
||||
"consumer_requirement": (
|
||||
"An installer must verify docker load produced the exact config-based image ID, "
|
||||
"then use that sha256 image ID as the offline Compose override. The registry "
|
||||
"reference is online-only because docker load does not restore RepoDigests. "
|
||||
"This assembler does not import images."
|
||||
),
|
||||
},
|
||||
"files": indexed_records,
|
||||
"claims_not_made": [
|
||||
"This manifest does not claim that an installer consumed its Docker import plan.",
|
||||
"This manifest does not claim installation, deployment, migration, health, UAT, rollback, or public-release approval."
|
||||
],
|
||||
}
|
||||
installable = with_payload_digest(installable_without_digest, "manifest_payload_sha256")
|
||||
if write_output:
|
||||
_write_exclusive_manifest(output, installable)
|
||||
return installable
|
||||
|
||||
|
||||
def validate_installable_release_manifest(
|
||||
*,
|
||||
artifact_root: Path,
|
||||
manifest_path: Path,
|
||||
expected_receipt_sha256: str,
|
||||
repo: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""Rebuild and byte-compare an existing manifest with the strict assembler."""
|
||||
|
||||
manifest_relative = _path_relative_to_root(
|
||||
artifact_root,
|
||||
manifest_path,
|
||||
label="installable release manifest",
|
||||
)
|
||||
if Path(manifest_relative).parent != Path("."):
|
||||
raise ReleaseValidationError(
|
||||
"installable release manifest must be directly in the artifact root"
|
||||
)
|
||||
manifest_bytes = manifest_path.read_bytes()
|
||||
manifest = load_json_file(manifest_path, label="installable release manifest")
|
||||
if canonical_json_bytes(manifest) != manifest_bytes:
|
||||
raise ReleaseValidationError(
|
||||
"installable release manifest is not serialized as canonical JSON"
|
||||
)
|
||||
build_receipt = _mapping(manifest.get("build_receipt"), "installable build_receipt")
|
||||
if set(build_receipt) != {"path", "sha256", "size_bytes", "payload_sha256"}:
|
||||
raise ReleaseValidationError("installable build_receipt fields are missing or unknown")
|
||||
receipt_relative = _record_path(build_receipt, "installable build_receipt")
|
||||
receipt_path = ensure_regular_file_under(artifact_root, receipt_relative)
|
||||
expected = assemble_installable_release(
|
||||
artifact_root=artifact_root,
|
||||
receipt_path=receipt_path,
|
||||
expected_receipt_sha256=expected_receipt_sha256,
|
||||
repo=repo,
|
||||
output=manifest_path,
|
||||
write_output=False,
|
||||
)
|
||||
if manifest != expected:
|
||||
raise ReleaseValidationError(
|
||||
"installable release manifest differs from the strict reconstructed manifest"
|
||||
)
|
||||
return manifest
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--artifact-root", type=Path, required=True)
|
||||
parser.add_argument("--receipt", type=Path, help="defaults to ARTIFACT_ROOT/build-receipt.json")
|
||||
parser.add_argument(
|
||||
"--expected-build-receipt-sha256",
|
||||
required=True,
|
||||
help="Independently supplied raw SHA-256 of the canonical build receipt.",
|
||||
)
|
||||
parser.add_argument("--repo", type=Path, default=Path.cwd(), help="Git repository containing the exact tag")
|
||||
parser.add_argument("--output", type=Path, help="defaults to ARTIFACT_ROOT/installable-release-manifest.json")
|
||||
args = parser.parse_args(argv)
|
||||
root = args.artifact_root.resolve()
|
||||
receipt = args.receipt.resolve() if args.receipt else root / "build-receipt.json"
|
||||
output = args.output.resolve() if args.output else root / "installable-release-manifest.json"
|
||||
try:
|
||||
manifest = assemble_installable_release(
|
||||
artifact_root=root,
|
||||
receipt_path=receipt,
|
||||
expected_receipt_sha256=args.expected_build_receipt_sha256,
|
||||
repo=args.repo.resolve(),
|
||||
output=output,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "passed",
|
||||
"output": str(output),
|
||||
"commit": manifest["candidate"]["commit"],
|
||||
"images": len(manifest["images"]),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
except (OSError, ReleaseValidationError) as error:
|
||||
print(f"installable_release_error: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -7,7 +7,9 @@ import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
@@ -37,6 +39,33 @@ REQUIREMENT_LOCK_PATHS = (
|
||||
"yuqei-infra/requirements/contract-api-linux-x86_64-py312.txt",
|
||||
"yuqei-infra/requirements/ai-runtime-linux-x86_64-py312.txt",
|
||||
)
|
||||
_WINDOWS_GIT_EXECUTABLE = (
|
||||
None
|
||||
if sys.platform.startswith("linux")
|
||||
else (
|
||||
str(Path(discovered).resolve())
|
||||
if (discovered := shutil.which("git")) is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
RELEASE_CONTROL_PATHS = (
|
||||
"yuqei-infra/scripts/build-release-candidate-manifest.py",
|
||||
"yuqei-infra/scripts/build-release-worktree-ownership.py",
|
||||
"yuqei-infra/compose/docker-compose.production.offline.yml",
|
||||
"yuqei-infra/deploy/preflight-production.py",
|
||||
"yuqei-infra/deploy/verify-production-compose.py",
|
||||
"yuqei-infra/deploy/release-package.py",
|
||||
"yuqei-infra/release/linux-release-security-policy.v1.json",
|
||||
"yuqei-infra/scripts/linux_release_common.py",
|
||||
"yuqei-infra/scripts/run-linux-release-build.py",
|
||||
"yuqei-infra/scripts/build-installable-release.py",
|
||||
"yuqei-infra/tests/test_production_compose_preflight.py",
|
||||
"yuqei-infra/tests/test_production_compose_merge_gate.py",
|
||||
"yuqei-infra/tests/test_release_package_executor.py",
|
||||
"yuqei-infra/tests/test_linux_release_build_tools.py",
|
||||
"yuqei-infra/tests/test_installable_release_assembler_hardening.py",
|
||||
"yuqei-infra/tests/test_release_candidate_manifest_tools.py",
|
||||
)
|
||||
CONTRACT_LOCK_PATH = REQUIREMENT_LOCK_PATHS[0]
|
||||
CONTRACT_DOCKERFILE_PATH = "yuqei-infra/docker/contract-api.Dockerfile"
|
||||
REQUIRED_PATHS = (
|
||||
@@ -47,6 +76,7 @@ REQUIRED_PATHS = (
|
||||
*OPENAPI_PATHS,
|
||||
*PROMPT_DEFINITION_PATHS,
|
||||
*REQUIREMENT_LOCK_PATHS,
|
||||
*RELEASE_CONTROL_PATHS,
|
||||
BASELINE_PATH,
|
||||
"yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py",
|
||||
"yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py",
|
||||
@@ -113,11 +143,49 @@ class TreeEntry:
|
||||
path: str
|
||||
|
||||
|
||||
def _controlled_git_environment() -> dict[str, str]:
|
||||
if sys.platform.startswith("linux"):
|
||||
environment = {
|
||||
"HOME": "/var/empty",
|
||||
"LANG": "C.UTF-8",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
}
|
||||
else:
|
||||
environment = {
|
||||
key: os.environ[key]
|
||||
for key in ("SYSTEMROOT", "WINDIR", "TEMP", "TMP")
|
||||
if key in os.environ
|
||||
}
|
||||
if not _WINDOWS_GIT_EXECUTABLE:
|
||||
raise ManifestError("Git executable is unavailable")
|
||||
environment["PATH"] = str(Path(_WINDOWS_GIT_EXECUTABLE).parent)
|
||||
environment.update({"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"})
|
||||
environment.update(
|
||||
{
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_NO_REPLACE_OBJECTS": "1",
|
||||
"GIT_OPTIONAL_LOCKS": "0",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
)
|
||||
return environment
|
||||
|
||||
|
||||
def _git_command(repo: Path, *args: str) -> list[str]:
|
||||
git_path = "/usr/bin/git" if sys.platform.startswith("linux") else _WINDOWS_GIT_EXECUTABLE
|
||||
if not git_path:
|
||||
raise ManifestError("Git executable is unavailable")
|
||||
return [str(git_path), "--no-replace-objects", "-C", str(repo), *args]
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str, binary: bool = False) -> bytes | str:
|
||||
completed = subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
_git_command(repo, *args),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
env=_controlled_git_environment(),
|
||||
timeout=30,
|
||||
)
|
||||
if completed.returncode:
|
||||
message = completed.stderr.decode("utf-8", "replace").strip()
|
||||
@@ -136,6 +204,11 @@ def _is_within(path: Path, parent: Path) -> bool:
|
||||
def validate_candidate_identity(repo: Path, commit: str, output: Path) -> str:
|
||||
if not FULL_COMMIT_RE.fullmatch(commit):
|
||||
raise ManifestError("--commit must be a full hexadecimal commit object id; names such as HEAD are refused")
|
||||
replacement_refs = str(
|
||||
_git(repo, "for-each-ref", "--format=%(refname)", "refs/replace/")
|
||||
)
|
||||
if replacement_refs:
|
||||
raise ManifestError("repository contains forbidden refs/replace entries")
|
||||
resolved = str(_git(repo, "rev-parse", "--verify", f"{commit}^{{commit}}"))
|
||||
if resolved != commit:
|
||||
raise ManifestError(f"commit object id did not resolve exactly: {commit} -> {resolved}")
|
||||
@@ -174,10 +247,11 @@ def list_commit_tree(repo: Path, commit: str) -> list[TreeEntry]:
|
||||
|
||||
def read_commit_blobs(repo: Path, entries: Iterable[TreeEntry]) -> dict[str, bytes]:
|
||||
process = subprocess.Popen(
|
||||
["git", "-C", str(repo), "cat-file", "--batch"],
|
||||
_git_command(repo, "cat-file", "--batch"),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=_controlled_git_environment(),
|
||||
)
|
||||
assert process.stdin is not None and process.stdout is not None and process.stderr is not None
|
||||
blobs: dict[str, bytes] = {}
|
||||
@@ -440,6 +514,9 @@ def configuration_summary(blobs: dict[str, bytes]) -> list[dict[str, Any]]:
|
||||
name.endswith("Dockerfile")
|
||||
or path.startswith("yuqei-infra/compose/")
|
||||
or path.startswith("yuqei-infra/nginx/")
|
||||
or path.startswith("yuqei-infra/deploy/")
|
||||
or path.startswith("yuqei-infra/release/")
|
||||
or path in RELEASE_CONTROL_PATHS
|
||||
or name in {"pyproject.toml", "package.json", "package-lock.json", "alembic.ini", "tsconfig.base.json"}
|
||||
or path in REQUIREMENT_LOCK_PATHS
|
||||
):
|
||||
@@ -455,7 +532,20 @@ def runtime_graph_summary(blobs: dict[str, bytes]) -> list[dict[str, Any]]:
|
||||
("web_bff_design_system", ("yuqei-contract-platform/apps/contract-web/src/", "yuqei-contract-platform/packages/design-system/src/")),
|
||||
("openapi", OPENAPI_PATHS),
|
||||
("migrations", ("yuqei-contract-platform/services/contract-api/migrations/", "yuqei-ai-platform/services/ai-platform-api/migrations/")),
|
||||
("deployment", ("yuqei-infra/docker/", "yuqei-infra/compose/", "yuqei-infra/nginx/")),
|
||||
(
|
||||
"deployment_and_release_controls",
|
||||
(
|
||||
"yuqei-infra/docker/",
|
||||
"yuqei-infra/compose/",
|
||||
"yuqei-infra/nginx/",
|
||||
"yuqei-infra/deploy/",
|
||||
"yuqei-infra/release/",
|
||||
"yuqei-infra/scripts/linux_release_common.py",
|
||||
"yuqei-infra/scripts/run-linux-release-build.py",
|
||||
"yuqei-infra/scripts/build-installable-release.py",
|
||||
*RELEASE_CONTROL_PATHS,
|
||||
),
|
||||
),
|
||||
)
|
||||
result = []
|
||||
for name, prefixes in components:
|
||||
|
||||
@@ -78,6 +78,14 @@ RUNTIME_COMPONENTS = {
|
||||
"yuqei-infra/compose/",
|
||||
"yuqei-infra/nginx/",
|
||||
"yuqei-infra/requirements/",
|
||||
"yuqei-infra/deploy/",
|
||||
"yuqei-infra/release/",
|
||||
"yuqei-infra/scripts/linux_release_common.py",
|
||||
"yuqei-infra/scripts/run-linux-release-build.py",
|
||||
"yuqei-infra/scripts/build-installable-release.py",
|
||||
"yuqei-infra/scripts/build-release-candidate-manifest.py",
|
||||
"yuqei-infra/scripts/build-release-worktree-ownership.py",
|
||||
"yuqei-infra/tests/test_installable_release_assembler_hardening.py",
|
||||
),
|
||||
}
|
||||
REQUIRED_WORKTREE_ANCHORS = (
|
||||
@@ -104,7 +112,23 @@ REQUIRED_WORKTREE_ANCHORS = (
|
||||
"yuqei-infra/docker/contract-web.Dockerfile",
|
||||
"yuqei-infra/compose/docker-compose.yml",
|
||||
"yuqei-infra/compose/docker-compose.production.yml",
|
||||
"yuqei-infra/compose/docker-compose.production.offline.yml",
|
||||
"yuqei-infra/nginx/templates/default.conf.template",
|
||||
"yuqei-infra/scripts/build-release-candidate-manifest.py",
|
||||
"yuqei-infra/scripts/build-release-worktree-ownership.py",
|
||||
"yuqei-infra/deploy/preflight-production.py",
|
||||
"yuqei-infra/deploy/verify-production-compose.py",
|
||||
"yuqei-infra/deploy/release-package.py",
|
||||
"yuqei-infra/release/linux-release-security-policy.v1.json",
|
||||
"yuqei-infra/scripts/linux_release_common.py",
|
||||
"yuqei-infra/scripts/run-linux-release-build.py",
|
||||
"yuqei-infra/scripts/build-installable-release.py",
|
||||
"yuqei-infra/tests/test_production_compose_preflight.py",
|
||||
"yuqei-infra/tests/test_production_compose_merge_gate.py",
|
||||
"yuqei-infra/tests/test_release_package_executor.py",
|
||||
"yuqei-infra/tests/test_linux_release_build_tools.py",
|
||||
"yuqei-infra/tests/test_installable_release_assembler_hardening.py",
|
||||
"yuqei-infra/tests/test_release_candidate_manifest_tools.py",
|
||||
)
|
||||
|
||||
|
||||
@@ -354,7 +378,20 @@ def closure_audit(repo: Path, entries: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
config_paths = [
|
||||
path
|
||||
for path in REQUIRED_WORKTREE_ANCHORS
|
||||
if path.startswith(("yuqei-infra/docker/", "yuqei-infra/compose/", "yuqei-infra/nginx/"))
|
||||
if path.startswith(
|
||||
(
|
||||
"yuqei-infra/docker/",
|
||||
"yuqei-infra/compose/",
|
||||
"yuqei-infra/nginx/",
|
||||
"yuqei-infra/deploy/",
|
||||
"yuqei-infra/release/",
|
||||
)
|
||||
)
|
||||
or path in {
|
||||
"yuqei-infra/scripts/linux_release_common.py",
|
||||
"yuqei-infra/scripts/run-linux-release-build.py",
|
||||
"yuqei-infra/scripts/build-installable-release.py",
|
||||
}
|
||||
]
|
||||
ownership_blockers = [
|
||||
item["path"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import errno
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load_module(path: Path, name: str) -> Any:
|
||||
existing = sys.modules.get(name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
assembler = _load_module(
|
||||
REPO_ROOT / "yuqei-infra/scripts/build-installable-release.py",
|
||||
"installable_release_assembler_hardening",
|
||||
)
|
||||
build_helpers = _load_module(
|
||||
REPO_ROOT / "yuqei-infra/tests/test_linux_release_build_tools.py",
|
||||
"installable_release_assembler_fixture_helpers",
|
||||
)
|
||||
|
||||
|
||||
def test_out_of_band_receipt_sha_mismatch_rejects_without_output(tmp_path: Path) -> None:
|
||||
bundle = build_helpers._bundle(tmp_path)
|
||||
output = bundle["root"] / "installable-release-manifest.json"
|
||||
|
||||
with pytest.raises(
|
||||
assembler.ReleaseValidationError,
|
||||
match="independently supplied raw SHA-256",
|
||||
):
|
||||
assembler.assemble_installable_release(
|
||||
artifact_root=bundle["root"],
|
||||
receipt_path=bundle["receipt_path"],
|
||||
expected_receipt_sha256="0" * 64,
|
||||
repo=bundle["repo"],
|
||||
output=output,
|
||||
)
|
||||
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_exclusive_writer_refuses_to_overwrite_existing_file(tmp_path: Path) -> None:
|
||||
output = tmp_path / "installable-release-manifest.json"
|
||||
output.write_bytes(b"trusted-existing-bytes")
|
||||
|
||||
with pytest.raises(
|
||||
assembler.ReleaseValidationError,
|
||||
match="must not already exist",
|
||||
):
|
||||
assembler._write_exclusive_manifest(output, {"new": "payload"})
|
||||
|
||||
assert output.read_bytes() == b"trusted-existing-bytes"
|
||||
|
||||
|
||||
def test_exclusive_writer_refuses_symlink_without_touching_target(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
target = tmp_path / "trusted-target.json"
|
||||
target.write_bytes(b"trusted-target-bytes")
|
||||
output = tmp_path / "installable-release-manifest.json"
|
||||
try:
|
||||
output.symlink_to(target)
|
||||
except OSError:
|
||||
original_open = os.open
|
||||
|
||||
def simulated_no_follow(path, flags, mode=0o777, *, dir_fd=None):
|
||||
if Path(path) == output:
|
||||
assert flags & getattr(os, "O_NOFOLLOW", 0) or os.name == "nt"
|
||||
raise OSError(errno.ELOOP, "simulated no-follow symlink rejection")
|
||||
if dir_fd is None:
|
||||
return original_open(path, flags, mode)
|
||||
return original_open(path, flags, mode, dir_fd=dir_fd)
|
||||
|
||||
monkeypatch.setattr(assembler.os, "open", simulated_no_follow)
|
||||
|
||||
with pytest.raises(
|
||||
assembler.ReleaseValidationError,
|
||||
match="must not already exist|must not be a symbolic link",
|
||||
):
|
||||
assembler._write_exclusive_manifest(output, {"new": "payload"})
|
||||
|
||||
if output.exists():
|
||||
assert output.is_symlink()
|
||||
assert target.read_bytes() == b"trusted-target-bytes"
|
||||
|
||||
|
||||
def test_exclusive_writer_rejects_link_count_change(tmp_path: Path, monkeypatch) -> None:
|
||||
output = tmp_path / "installable-release-manifest.json"
|
||||
original_fstat = os.fstat
|
||||
|
||||
def forged_fstat(descriptor: int) -> os.stat_result:
|
||||
result = original_fstat(descriptor)
|
||||
values = list(result)
|
||||
values[3] = 2
|
||||
return os.stat_result(values)
|
||||
|
||||
monkeypatch.setattr(assembler.os, "fstat", forged_fstat)
|
||||
with pytest.raises(
|
||||
assembler.ReleaseValidationError,
|
||||
match="single-link regular file",
|
||||
):
|
||||
assembler._write_exclusive_manifest(output, {"new": "payload"})
|
||||
|
||||
assert output.exists()
|
||||
assert output.read_bytes() == b""
|
||||
|
||||
|
||||
def test_exclusive_writer_rejects_output_path_identity_change(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
output = tmp_path / "installable-release-manifest.json"
|
||||
original_lstat = os.lstat
|
||||
|
||||
def forged_lstat(path: os.PathLike[str] | str) -> os.stat_result:
|
||||
result = original_lstat(path)
|
||||
if Path(path) == output:
|
||||
values = list(result)
|
||||
values[1] = int(result.st_ino) + 1
|
||||
return os.stat_result(values)
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(assembler.os, "lstat", forged_lstat)
|
||||
with pytest.raises(
|
||||
assembler.ReleaseValidationError,
|
||||
match="output path changed identity",
|
||||
):
|
||||
assembler._write_exclusive_manifest(output, {"new": "payload"})
|
||||
|
||||
|
||||
def test_exclusive_writer_is_owner_only_and_fsyncs_file_and_directory(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
output = tmp_path / "installable-release-manifest.json"
|
||||
original_fsync = os.fsync
|
||||
fsync_calls: list[int] = []
|
||||
|
||||
def recording_fsync(descriptor: int) -> None:
|
||||
fsync_calls.append(descriptor)
|
||||
original_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(assembler.os, "fsync", recording_fsync)
|
||||
assembler._write_exclusive_manifest(output, {"schema_version": "test.v1"})
|
||||
|
||||
assert output.read_bytes() == assembler.canonical_json_bytes(
|
||||
{"schema_version": "test.v1"}
|
||||
)
|
||||
assert len(fsync_calls) >= (1 if os.name == "nt" else 2)
|
||||
if os.name != "nt":
|
||||
assert stat.S_IMODE(output.stat().st_mode) == 0o600
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load_script():
|
||||
path = REPO_ROOT / "yuqei-infra/deploy/verify-production-compose.py"
|
||||
spec = importlib.util.spec_from_file_location("verify_production_compose", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
gate = _load_script()
|
||||
|
||||
|
||||
@dataclass
|
||||
class GateFixture:
|
||||
repo: Path
|
||||
env_file: Path
|
||||
images: dict[str, str]
|
||||
base: Path
|
||||
production: Path
|
||||
offline: Path
|
||||
|
||||
|
||||
def _image(name: str, digit: str) -> str:
|
||||
return f"registry.example.test/yuqei/{name}@sha256:{digit * 64}"
|
||||
|
||||
|
||||
def _make_fixture(tmp_path: Path) -> GateFixture:
|
||||
repo = tmp_path / "repo"
|
||||
base = repo / gate.BASE_COMPOSE_RELATIVE
|
||||
production = repo / gate.PRODUCTION_COMPOSE_RELATIVE
|
||||
offline = repo / gate.OFFLINE_COMPOSE_RELATIVE
|
||||
base.parent.mkdir(parents=True)
|
||||
base.write_text("services: {}\n", encoding="utf-8", newline="\n")
|
||||
production.write_text("services: {}\n", encoding="utf-8", newline="\n")
|
||||
offline.write_text("services: {}\n", encoding="utf-8", newline="\n")
|
||||
images = {
|
||||
"CONTRACT_API_IMAGE": _image("contract-api", "1"),
|
||||
"CONTRACT_WEB_IMAGE": _image("contract-web", "2"),
|
||||
"AI_PLATFORM_API_IMAGE": _image("ai-platform-api", "3"),
|
||||
"AI_WORKER_IMAGE": _image("ai-worker", "4"),
|
||||
}
|
||||
env_file = tmp_path / "strict-production.env"
|
||||
env_file.write_text(
|
||||
"COMPOSE_PROJECT_NAME=yuqei-prod\n"
|
||||
+ "".join(f"{key}={value}\n" for key, value in images.items()),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
return GateFixture(repo, env_file, images, base, production, offline)
|
||||
|
||||
|
||||
def _profiles(command: list[str]) -> set[str]:
|
||||
return {
|
||||
command[index + 1]
|
||||
for index, token in enumerate(command[:-1])
|
||||
if token == "--profile"
|
||||
}
|
||||
|
||||
|
||||
def _compose_files(command: list[str]) -> list[str]:
|
||||
return [
|
||||
command[index + 1]
|
||||
for index, token in enumerate(command[:-1])
|
||||
if token == "-f"
|
||||
]
|
||||
|
||||
|
||||
class FakeComposeRunner:
|
||||
def __init__(
|
||||
self,
|
||||
fixture: GateFixture,
|
||||
mutate: Callable[[dict[str, Any], bool, set[str], list[str]], None] | None = None,
|
||||
) -> None:
|
||||
self.fixture = fixture
|
||||
self.mutate = mutate
|
||||
self.calls: list[tuple[list[str], dict[str, Any]]] = []
|
||||
|
||||
def __call__(self, command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
self.calls.append((list(command), kwargs))
|
||||
profiles = _profiles(command)
|
||||
offline = str(self.fixture.offline.resolve()) in _compose_files(command)
|
||||
services: dict[str, dict[str, Any]] = {}
|
||||
for service_name in gate.PRODUCTION_SERVICES:
|
||||
required_profile = gate.PROFILE_ONLY_SERVICES.get(service_name)
|
||||
if required_profile is not None and required_profile not in profiles:
|
||||
continue
|
||||
service: dict[str, Any] = {
|
||||
"image": f"registry.example.test/infra/{service_name}@sha256:{'e' * 64}"
|
||||
}
|
||||
image_env = gate.APPLICATION_SERVICE_IMAGE_ENV.get(service_name)
|
||||
if image_env is not None:
|
||||
service["image"] = self.fixture.images[image_env]
|
||||
service["pull_policy"] = "never" if offline else "always"
|
||||
elif offline:
|
||||
service["pull_policy"] = "never"
|
||||
services[service_name] = service
|
||||
if self.mutate is not None:
|
||||
self.mutate(services, offline, profiles, command)
|
||||
stdout = json.dumps({"name": "yuqei-prod", "services": services})
|
||||
return subprocess.CompletedProcess(command, 0, stdout=stdout, stderr="")
|
||||
|
||||
|
||||
def test_gate_renders_online_and_offline_for_all_profile_combinations(tmp_path: Path) -> None:
|
||||
fixture = _make_fixture(tmp_path)
|
||||
runner = FakeComposeRunner(fixture)
|
||||
|
||||
report = gate.verify_production_compose(
|
||||
env_file=fixture.env_file,
|
||||
repo_root=fixture.repo,
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
assert report["status"] == "passed"
|
||||
assert report["application_service_count"] == 9
|
||||
assert report["production_service_count"] == 16
|
||||
assert len(report["cases"]) == 8
|
||||
assert [(case["profile_case"], case["mode"]) for case in report["cases"]] == [
|
||||
("default", "online"),
|
||||
("default", "offline"),
|
||||
("backend-hosted-web", "online"),
|
||||
("backend-hosted-web", "offline"),
|
||||
("ops", "online"),
|
||||
("ops", "offline"),
|
||||
("backend-hosted-web+ops", "online"),
|
||||
("backend-hosted-web+ops", "offline"),
|
||||
]
|
||||
assert [case["service_count"] for case in report["cases"]] == [
|
||||
13,
|
||||
13,
|
||||
14,
|
||||
14,
|
||||
15,
|
||||
15,
|
||||
16,
|
||||
16,
|
||||
]
|
||||
|
||||
assert len(runner.calls) == 8
|
||||
for command, kwargs in runner.calls:
|
||||
assert command[:4] == [
|
||||
gate.DOCKER_CLI,
|
||||
"--host",
|
||||
gate.LOCAL_DOCKER_HOST,
|
||||
"compose",
|
||||
]
|
||||
files = _compose_files(command)
|
||||
assert files[:2] == [str(fixture.base.resolve()), str(fixture.production.resolve())]
|
||||
if str(fixture.offline.resolve()) in files:
|
||||
assert files == [
|
||||
str(fixture.base.resolve()),
|
||||
str(fixture.production.resolve()),
|
||||
str(fixture.offline.resolve()),
|
||||
]
|
||||
else:
|
||||
assert files == [str(fixture.base.resolve()), str(fixture.production.resolve())]
|
||||
assert command[-3:] == ["config", "--format", "json"]
|
||||
assert all(token not in command for token in ("up", "pull", "build", "down", "run"))
|
||||
assert kwargs["shell"] is False
|
||||
assert kwargs["check"] is False
|
||||
assert kwargs["cwd"] == str(fixture.repo.resolve())
|
||||
assert kwargs["env"] == {
|
||||
"HOME": "/var/empty",
|
||||
"LANG": "C.UTF-8",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
}
|
||||
|
||||
assert report["env_file_sha256"] == hashlib.sha256(fixture.env_file.read_bytes()).hexdigest()
|
||||
assert [item["path"] for item in report["compose_inputs"]] == [
|
||||
gate.BASE_COMPOSE_RELATIVE,
|
||||
gate.PRODUCTION_COMPOSE_RELATIVE,
|
||||
gate.OFFLINE_COMPOSE_RELATIVE,
|
||||
]
|
||||
assert [item["sha256"] for item in report["compose_inputs"]] == [
|
||||
hashlib.sha256(fixture.base.read_bytes()).hexdigest(),
|
||||
hashlib.sha256(fixture.production.read_bytes()).hexdigest(),
|
||||
hashlib.sha256(fixture.offline.read_bytes()).hexdigest(),
|
||||
]
|
||||
|
||||
|
||||
def test_hardcoded_latest_image_mutation_fails_closed(tmp_path: Path) -> None:
|
||||
fixture = _make_fixture(tmp_path)
|
||||
|
||||
def mutate(services, _offline, _profiles, _command):
|
||||
services["contract-api"]["image"] = "registry.example.test/yuqei/contract-api:latest"
|
||||
|
||||
runner = FakeComposeRunner(fixture, mutate)
|
||||
with pytest.raises(gate.ComposeGateError, match="exactly match CONTRACT_API_IMAGE"):
|
||||
gate.verify_production_compose(
|
||||
env_file=fixture.env_file,
|
||||
repo_root=fixture.repo,
|
||||
runner=runner,
|
||||
)
|
||||
assert len(runner.calls) == 1
|
||||
|
||||
|
||||
def test_application_build_mutation_fails_closed(tmp_path: Path) -> None:
|
||||
fixture = _make_fixture(tmp_path)
|
||||
|
||||
def mutate(services, _offline, _profiles, _command):
|
||||
services["worker"]["build"] = {"context": "../.."}
|
||||
|
||||
with pytest.raises(gate.ComposeGateError, match="retains a build definition"):
|
||||
gate.verify_production_compose(
|
||||
env_file=fixture.env_file,
|
||||
repo_root=fixture.repo,
|
||||
runner=FakeComposeRunner(fixture, mutate),
|
||||
)
|
||||
|
||||
|
||||
def test_online_pull_policy_mutation_fails_closed(tmp_path: Path) -> None:
|
||||
fixture = _make_fixture(tmp_path)
|
||||
|
||||
def mutate(services, offline, _profiles, _command):
|
||||
if not offline:
|
||||
services["ai-platform-api"]["pull_policy"] = "missing"
|
||||
|
||||
with pytest.raises(gate.ComposeGateError, match="pull_policy must be always"):
|
||||
gate.verify_production_compose(
|
||||
env_file=fixture.env_file,
|
||||
repo_root=fixture.repo,
|
||||
runner=FakeComposeRunner(fixture, mutate),
|
||||
)
|
||||
|
||||
|
||||
def test_offline_pull_policy_and_build_are_checked_for_infrastructure(tmp_path: Path) -> None:
|
||||
fixture = _make_fixture(tmp_path)
|
||||
|
||||
def mutate(services, offline, _profiles, _command):
|
||||
if offline:
|
||||
services["edge"]["pull_policy"] = "always"
|
||||
services["postgres"]["build"] = "."
|
||||
|
||||
with pytest.raises(gate.ComposeGateError, match="service edge pull_policy must be never"):
|
||||
gate.verify_production_compose(
|
||||
env_file=fixture.env_file,
|
||||
repo_root=fixture.repo,
|
||||
runner=FakeComposeRunner(fixture, mutate),
|
||||
)
|
||||
|
||||
|
||||
def test_both_profiles_require_all_sixteen_services(tmp_path: Path) -> None:
|
||||
fixture = _make_fixture(tmp_path)
|
||||
|
||||
def mutate(services, _offline, profiles, _command):
|
||||
if profiles == {"backend-hosted-web", "ops"}:
|
||||
services.pop("minio-backup")
|
||||
|
||||
runner = FakeComposeRunner(fixture, mutate)
|
||||
with pytest.raises(gate.ComposeGateError, match="required services are missing: minio-backup"):
|
||||
gate.verify_production_compose(
|
||||
env_file=fixture.env_file,
|
||||
repo_root=fixture.repo,
|
||||
runner=runner,
|
||||
)
|
||||
assert len(runner.calls) == 7
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("line", "message"),
|
||||
[
|
||||
("COMPOSE_PROJECT_NAME=prod;touch-pwned", "missing or unsafe"),
|
||||
("CONTRACT_API_IMAGE=registry.example.test/yuqei/contract-api:latest", "immutable"),
|
||||
],
|
||||
)
|
||||
def test_strict_env_rejects_command_injection_and_mutable_images(
|
||||
tmp_path: Path, line: str, message: str
|
||||
) -> None:
|
||||
fixture = _make_fixture(tmp_path)
|
||||
lines = fixture.env_file.read_text(encoding="utf-8").splitlines()
|
||||
key = line.split("=", 1)[0]
|
||||
fixture.env_file.write_text(
|
||||
"\n".join(line if item.startswith(key + "=") else item for item in lines) + "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
runner = FakeComposeRunner(fixture)
|
||||
|
||||
with pytest.raises(gate.ComposeGateError, match=message):
|
||||
gate.verify_production_compose(
|
||||
env_file=fixture.env_file,
|
||||
repo_root=fixture.repo,
|
||||
runner=runner,
|
||||
)
|
||||
assert runner.calls == []
|
||||
|
||||
|
||||
def test_duplicate_env_key_fails_before_runner(tmp_path: Path) -> None:
|
||||
fixture = _make_fixture(tmp_path)
|
||||
with fixture.env_file.open("a", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write("CONTRACT_WEB_IMAGE=" + fixture.images["CONTRACT_WEB_IMAGE"] + "\n")
|
||||
runner = FakeComposeRunner(fixture)
|
||||
|
||||
with pytest.raises(gate.ComposeGateError, match="duplicate strict env key"):
|
||||
gate.verify_production_compose(
|
||||
env_file=fixture.env_file,
|
||||
repo_root=fixture.repo,
|
||||
runner=runner,
|
||||
)
|
||||
assert runner.calls == []
|
||||
|
||||
|
||||
def test_missing_offline_override_fails_before_runner(tmp_path: Path) -> None:
|
||||
fixture = _make_fixture(tmp_path)
|
||||
fixture.offline.unlink()
|
||||
runner = FakeComposeRunner(fixture)
|
||||
|
||||
with pytest.raises(gate.ComposeGateError, match="offline Compose override is missing"):
|
||||
gate.verify_production_compose(
|
||||
env_file=fixture.env_file,
|
||||
repo_root=fixture.repo,
|
||||
runner=runner,
|
||||
)
|
||||
assert runner.calls == []
|
||||
|
||||
|
||||
def test_runner_failure_stops_at_first_config(tmp_path: Path) -> None:
|
||||
fixture = _make_fixture(tmp_path)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def runner(command, **_kwargs):
|
||||
calls.append(command)
|
||||
return subprocess.CompletedProcess(command, 23, stdout="", stderr="invalid merge")
|
||||
|
||||
with pytest.raises(gate.ComposeGateError, match="exit code 23"):
|
||||
gate.verify_production_compose(
|
||||
env_file=fixture.env_file,
|
||||
repo_root=fixture.repo,
|
||||
runner=runner,
|
||||
)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_unexpected_service_cannot_bypass_offline_policy_gate(tmp_path: Path) -> None:
|
||||
fixture = _make_fixture(tmp_path)
|
||||
|
||||
def mutate(services, _offline, _profiles, _command):
|
||||
services["unreviewed-sidecar"] = {"image": "example.invalid/sidecar:latest"}
|
||||
|
||||
with pytest.raises(gate.ComposeGateError, match="unexpected production services"):
|
||||
gate.verify_production_compose(
|
||||
env_file=fixture.env_file,
|
||||
repo_root=fixture.repo,
|
||||
runner=FakeComposeRunner(fixture, mutate),
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
COMPOSE_DIR = REPO_ROOT / "yuqei-infra" / "compose"
|
||||
BASE_COMPOSE = COMPOSE_DIR / "docker-compose.yml"
|
||||
PRODUCTION_COMPOSE = COMPOSE_DIR / "docker-compose.production.yml"
|
||||
OFFLINE_COMPOSE = COMPOSE_DIR / "docker-compose.production.offline.yml"
|
||||
ENV_EXAMPLE = COMPOSE_DIR / ".env.production.example"
|
||||
|
||||
APPLICATION_SERVICES = {
|
||||
"contract-api-migrate",
|
||||
"contract-api",
|
||||
"contract-review-worker",
|
||||
"contract-playbook-governance-worker",
|
||||
"ai-platform-api-migrate",
|
||||
"ai-platform-prompt-assets-import",
|
||||
"ai-platform-api",
|
||||
"worker",
|
||||
"contract-web",
|
||||
}
|
||||
PRODUCTION_IMAGE_SERVICES = APPLICATION_SERVICES | {
|
||||
"postgres",
|
||||
"postgres-init-databases",
|
||||
"redis",
|
||||
"minio",
|
||||
"edge",
|
||||
"contract-files-backup",
|
||||
"minio-backup",
|
||||
}
|
||||
PROFILE_SETS = (
|
||||
frozenset(),
|
||||
frozenset({"backend-hosted-web"}),
|
||||
frozenset({"ops"}),
|
||||
frozenset({"backend-hosted-web", "ops"}),
|
||||
)
|
||||
RESET_MARKER = "__compose_reset_marker__"
|
||||
|
||||
|
||||
def _load_script(name: str, relative_path: str) -> ModuleType:
|
||||
path = REPO_ROOT / relative_path
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
preflight = _load_script(
|
||||
"yuqei_production_compose_preflight",
|
||||
"yuqei-infra/deploy/preflight-production.py",
|
||||
)
|
||||
|
||||
|
||||
class _ComposeLoader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
|
||||
def _construct_reset(_loader: _ComposeLoader, _node: yaml.Node) -> str:
|
||||
return RESET_MARKER
|
||||
|
||||
|
||||
_ComposeLoader.add_constructor("!reset", _construct_reset)
|
||||
|
||||
|
||||
def _load_compose(path: Path) -> dict[str, Any]:
|
||||
document = yaml.load(path.read_text(encoding="utf-8"), Loader=_ComposeLoader)
|
||||
assert isinstance(document, dict)
|
||||
return document
|
||||
|
||||
|
||||
def _merge_mapping(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = copy.deepcopy(base)
|
||||
for key, value in override.items():
|
||||
if value == RESET_MARKER:
|
||||
merged.pop(key, None)
|
||||
elif isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = _merge_mapping(merged[key], value)
|
||||
else:
|
||||
merged[key] = copy.deepcopy(value)
|
||||
return merged
|
||||
|
||||
|
||||
def _selected_services(model: dict[str, Any], profiles: frozenset[str]) -> dict[str, Any]:
|
||||
selected: dict[str, Any] = {}
|
||||
for name, service in model["services"].items():
|
||||
service_profiles = set(service.get("profiles", []))
|
||||
if not service_profiles or service_profiles & profiles:
|
||||
selected[name] = service
|
||||
return selected
|
||||
|
||||
|
||||
def _production_model() -> dict[str, Any]:
|
||||
return _merge_mapping(_load_compose(BASE_COMPOSE), _load_compose(PRODUCTION_COMPOSE))
|
||||
|
||||
|
||||
def test_production_merge_removes_all_inherited_application_builds() -> None:
|
||||
base = _load_compose(BASE_COMPOSE)
|
||||
assert {
|
||||
name for name, service in base["services"].items() if "build" in service
|
||||
} == APPLICATION_SERVICES
|
||||
|
||||
production = _production_model()
|
||||
for profiles in PROFILE_SETS:
|
||||
selected = _selected_services(production, profiles)
|
||||
assert all("build" not in service for service in selected.values())
|
||||
|
||||
for name in APPLICATION_SERVICES:
|
||||
service = production["services"][name]
|
||||
assert "build" not in service
|
||||
assert service["image"]
|
||||
assert service["pull_policy"] == "always"
|
||||
|
||||
|
||||
def test_offline_merge_disables_pulls_for_every_production_profile() -> None:
|
||||
offline_override = _load_compose(OFFLINE_COMPOSE)
|
||||
assert set(offline_override["services"]) == PRODUCTION_IMAGE_SERVICES
|
||||
|
||||
offline = _merge_mapping(_production_model(), offline_override)
|
||||
assert set(offline["services"]) == PRODUCTION_IMAGE_SERVICES
|
||||
for profiles in PROFILE_SETS:
|
||||
selected = _selected_services(offline, profiles)
|
||||
assert all(service["pull_policy"] == "never" for service in selected.values())
|
||||
assert all("build" not in service for service in selected.values())
|
||||
|
||||
|
||||
def test_contract_web_image_is_required_and_must_be_immutable() -> None:
|
||||
values = preflight.parse_env_file(ENV_EXAMPLE)
|
||||
assert "CONTRACT_WEB_IMAGE" in values
|
||||
|
||||
values["CONTRACT_WEB_IMAGE"] = ""
|
||||
checks = {check.key: check for check in preflight.validate_environment(values)}
|
||||
assert checks["required_values"].status == "fail"
|
||||
assert "CONTRACT_WEB_IMAGE" in checks["required_values"].message
|
||||
|
||||
digest = "sha256:" + "d" * 64
|
||||
for key in (
|
||||
"CONTRACT_API_IMAGE",
|
||||
"CONTRACT_WEB_IMAGE",
|
||||
"AI_PLATFORM_API_IMAGE",
|
||||
"AI_WORKER_IMAGE",
|
||||
"POSTGRES_IMAGE",
|
||||
"POSTGRES_CLIENT_IMAGE",
|
||||
"REDIS_IMAGE",
|
||||
"MINIO_IMAGE",
|
||||
"MINIO_MC_IMAGE",
|
||||
"NGINX_IMAGE",
|
||||
"BACKUP_UTILITY_IMAGE",
|
||||
):
|
||||
values[key] = f"registry.internal/{key.lower()}@{digest}"
|
||||
|
||||
checks = {check.key: check for check in preflight.validate_environment(values)}
|
||||
assert checks["immutable_images"].status == "pass"
|
||||
|
||||
values["CONTRACT_WEB_IMAGE"] = "registry.internal/contract-web:latest"
|
||||
checks = {check.key: check for check in preflight.validate_environment(values)}
|
||||
assert checks["immutable_images"].status == "fail"
|
||||
assert "CONTRACT_WEB_IMAGE" in checks["immutable_images"].message
|
||||
@@ -58,6 +58,7 @@ def production_values() -> dict[str, str]:
|
||||
replacements = {
|
||||
"RELEASE_ID": "v2.0.0-rc.1",
|
||||
"CONTRACT_API_IMAGE": f"registry.internal/contract-api@sha256:{'1' * 64}",
|
||||
"CONTRACT_WEB_IMAGE": f"registry.internal/contract-web@sha256:{'c' * 64}",
|
||||
"AI_PLATFORM_API_IMAGE": f"registry.internal/ai-platform-api@sha256:{'2' * 64}",
|
||||
"AI_PLATFORM_RELEASE_IMAGE_DIGEST": f"sha256:{'2' * 64}",
|
||||
"CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST": f"sha256:{'1' * 64}",
|
||||
|
||||
@@ -305,6 +305,13 @@ 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:
|
||||
assembler_hardening_test = (
|
||||
"yuqei-infra/tests/test_installable_release_assembler_hardening.py"
|
||||
)
|
||||
assert assembler_hardening_test in manifest.RELEASE_CONTROL_PATHS
|
||||
assert assembler_hardening_test in ownership.REQUIRED_WORKTREE_ANCHORS
|
||||
assert assembler_hardening_test in ownership.RUNTIME_COMPONENTS["deployment"]
|
||||
assert ownership.classify_path(assembler_hardening_test)[0] == "release_test_include"
|
||||
pinned_bases = {
|
||||
"yuqei-infra/docker/contract-api.Dockerfile": (
|
||||
"ARG PYTHON_BASE_IMAGE=python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b",
|
||||
@@ -329,6 +336,18 @@ def test_manifest_is_deterministic_and_summarizes_complete_committed_graph(tmp_p
|
||||
assert from_line in lines
|
||||
assert sum(line.startswith("FROM ") for line in lines) == 1
|
||||
|
||||
web_dockerfile = (
|
||||
REPO_ROOT / "yuqei-infra/docker/contract-web.Dockerfile"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "/usr/local/lib/node_modules/npm" in web_dockerfile
|
||||
assert "USER node" in web_dockerfile.splitlines()
|
||||
assert (
|
||||
'CMD ["node", "/app/node_modules/next/dist/bin/next", "start", '
|
||||
'"/app/yuqei-contract-platform/apps/contract-web", "-p", "3000"]'
|
||||
in web_dockerfile
|
||||
)
|
||||
assert 'CMD ["npm"' not in web_dockerfile
|
||||
|
||||
dockerignore_rules = {
|
||||
line.strip()
|
||||
for line in (REPO_ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines()
|
||||
@@ -411,6 +430,16 @@ def test_manifest_is_deterministic_and_summarizes_complete_committed_graph(tmp_p
|
||||
assert first["summaries"]["runtime_compatibility"]["minimum_glibc"] == "2.28"
|
||||
assert first["summaries"]["migrations"][0]["heads"] == ["contract-head"]
|
||||
assert first["summaries"]["migrations"][1]["heads"] == ["ai-head"]
|
||||
recorded_paths = {item["path"] for item in first["files"]["items"]}
|
||||
assert set(manifest.RELEASE_CONTROL_PATHS) <= recorded_paths
|
||||
configuration_paths = {item["path"] for item in first["summaries"]["configuration"]}
|
||||
assert set(manifest.RELEASE_CONTROL_PATHS) <= configuration_paths
|
||||
release_component = next(
|
||||
item
|
||||
for item in first["summaries"]["runtime_build_graph"]
|
||||
if item["component"] == "deployment_and_release_controls"
|
||||
)
|
||||
assert release_component["file_count"] >= len(manifest.RELEASE_CONTROL_PATHS)
|
||||
package_record = next(item for item in first["files"]["items"] if item["path"] == "package.json")
|
||||
package_bytes = (repo / "package.json").read_bytes()
|
||||
assert package_record["size"] == len(package_bytes)
|
||||
@@ -431,6 +460,11 @@ def test_manifest_refuses_ambiguous_dirty_missing_and_secret_candidates(tmp_path
|
||||
without_package = [item for item in entries if item.path != "package.json"]
|
||||
with pytest.raises(manifest.ManifestError, match="missing_paths"):
|
||||
manifest.validate_tree_paths(without_package)
|
||||
without_release_control = [
|
||||
item for item in entries if item.path != manifest.RELEASE_CONTROL_PATHS[0]
|
||||
]
|
||||
with pytest.raises(manifest.ManifestError, match="missing_paths"):
|
||||
manifest.validate_tree_paths(without_release_control)
|
||||
|
||||
_write(repo, "dirty.txt", "not committed\n")
|
||||
with pytest.raises(manifest.ManifestError, match="working tree is dirty"):
|
||||
@@ -446,6 +480,65 @@ def test_manifest_refuses_ambiguous_dirty_missing_and_secret_candidates(tmp_path
|
||||
manifest.build_manifest(repo, secret_commit, external_output)
|
||||
|
||||
|
||||
def test_manifest_rejects_git_replace_refs_before_reading_candidate_objects(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repo, commit = _candidate_repo(tmp_path)
|
||||
tree = _git(repo, "rev-parse", f"{commit}^{{tree}}")
|
||||
replacement = _git(
|
||||
repo,
|
||||
"commit-tree",
|
||||
tree,
|
||||
"-p",
|
||||
commit,
|
||||
"-m",
|
||||
"forbidden replacement commit",
|
||||
)
|
||||
_git(repo, "replace", commit, replacement)
|
||||
|
||||
with pytest.raises(manifest.ManifestError, match="refs/replace"):
|
||||
manifest.build_manifest(repo, commit, tmp_path / "manifest.json")
|
||||
|
||||
|
||||
def test_manifest_git_run_and_batch_reads_disable_replacements_and_ambient_config(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
repo, commit = _candidate_repo(tmp_path)
|
||||
monkeypatch.setenv("GIT_CONFIG_SYSTEM", str(tmp_path / "attacker-system-config"))
|
||||
monkeypatch.setenv("GIT_REPLACE_REF_BASE", "refs/attacker-replace/")
|
||||
observed_runs: list[tuple[list[str], dict[str, object]]] = []
|
||||
observed_popen: list[tuple[list[str], dict[str, object]]] = []
|
||||
actual_run = subprocess.run
|
||||
actual_popen = subprocess.Popen
|
||||
|
||||
def capture_run(arguments, **kwargs):
|
||||
observed_runs.append((list(arguments), dict(kwargs)))
|
||||
return actual_run(arguments, **kwargs)
|
||||
|
||||
def capture_popen(arguments, **kwargs):
|
||||
observed_popen.append((list(arguments), dict(kwargs)))
|
||||
return actual_popen(arguments, **kwargs)
|
||||
|
||||
monkeypatch.setattr(manifest.subprocess, "run", capture_run)
|
||||
monkeypatch.setattr(manifest.subprocess, "Popen", capture_popen)
|
||||
assert manifest._git(repo, "rev-parse", "--verify", f"{commit}^{{commit}}") == commit
|
||||
entry = manifest.list_commit_tree(repo, commit)[0]
|
||||
blobs = manifest.read_commit_blobs(repo, [entry])
|
||||
assert entry.path in blobs
|
||||
|
||||
assert observed_runs
|
||||
assert observed_popen
|
||||
for arguments, kwargs in [*observed_runs, *observed_popen]:
|
||||
assert arguments[1] == "--no-replace-objects"
|
||||
child_env = kwargs["env"]
|
||||
assert child_env["GIT_CONFIG_NOSYSTEM"] == "1"
|
||||
assert child_env["GIT_NO_REPLACE_OBJECTS"] == "1"
|
||||
assert child_env["GIT_OPTIONAL_LOCKS"] == "0"
|
||||
assert child_env["GIT_TERMINAL_PROMPT"] == "0"
|
||||
assert "GIT_CONFIG_SYSTEM" not in child_env
|
||||
assert "GIT_REPLACE_REF_BASE" not in child_env
|
||||
|
||||
|
||||
def test_manifest_distinguishes_secret_domain_source_from_secret_material() -> None:
|
||||
safe_source_paths = (
|
||||
"yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/secret_refs.py",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user