diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6991b07f..2b400629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,6 +167,9 @@ jobs: echo "registryctl_tutorial=${registryctl_tutorial}" } >> "${GITHUB_OUTPUT}" + - name: Check Debian 13 image contract + run: python3 release/scripts/check-debian13-images.py + secrets: name: Secret scan runs-on: ubuntu-24.04 @@ -507,6 +510,9 @@ jobs: - name: Test release helper run: python3 -m unittest release/scripts/test_registry_release.py + - name: Test Debian 13 image contract checker + run: python3 -m unittest release/scripts/test_check_debian13_images.py + - name: Test release planning commands run: python3 -m unittest release/scripts/test_registry_release_plans.py @@ -644,9 +650,7 @@ jobs: path: | target/registryctl-tutorial-cargo-home target/registryctl-tutorial-linux-amd64 - key: registryctl-tutorial-${{ runner.os }}-rust-1.95.0-${{ hashFiles('Cargo.lock') }} - restore-keys: | - registryctl-tutorial-${{ runner.os }}-rust-1.95.0- + key: registryctl-tutorial-${{ runner.os }}-${{ hashFiles('docs/site/scripts/check-registryctl-tutorials.sh') }}-${{ hashFiles('Cargo.lock') }} - name: Execute registryctl tutorials from source working-directory: docs/site diff --git a/crates/registry-relay/scripts/run-live-consultation-journey.sh b/crates/registry-relay/scripts/run-live-consultation-journey.sh index 72ec2bce..334046d9 100755 --- a/crates/registry-relay/scripts/run-live-consultation-journey.sh +++ b/crates/registry-relay/scripts/run-live-consultation-journey.sh @@ -6,7 +6,7 @@ set +v set -euo pipefail umask 077 -readonly POSTGRES_IMAGE="postgres:16" +readonly POSTGRES_IMAGE="postgres:16.13-alpine" [[ "$#" -le 1 ]] || { printf '%s\n' "usage: $0 [dhis2|rhai|synthetic]" >&2 diff --git a/docs/site/scripts/check-registryctl-tutorials.sh b/docs/site/scripts/check-registryctl-tutorials.sh index 77e70304..de8c556b 100644 --- a/docs/site/scripts/check-registryctl-tutorials.sh +++ b/docs/site/scripts/check-registryctl-tutorials.sh @@ -15,7 +15,7 @@ REPO_ROOT="$(cd "$SITE_ROOT/../.." && pwd)" HELPER="$SITE_ROOT/scripts/registryctl-tutorial.mjs" RELAY_TUTORIAL="$SITE_ROOT/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx" NOTARY_TUTORIAL="$SITE_ROOT/src/content/docs/tutorials/verify-claim-registry-api.mdx" -BUILDER_IMAGE="rust:1.95-bookworm@sha256:4c2fd73ef19c5ef9d54bee03b06b2839a392604fbfcd578ed948b71b37c1d7fb" +BUILDER_IMAGE="rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3" LINUX_TARGET="$REPO_ROOT/target/registryctl-tutorial-linux-amd64" CARGO_HOME_DIR="$REPO_ROOT/target/registryctl-tutorial-cargo-home" NATIVE_TARGET="$REPO_ROOT/target/registryctl-tutorial-native" diff --git a/release/scripts/check-debian13-images.py b/release/scripts/check-debian13-images.py index b6aac87c..cd6d1ee7 100755 --- a/release/scripts/check-debian13-images.py +++ b/release/scripts/check-debian13-images.py @@ -7,6 +7,8 @@ import sys from pathlib import Path +import yaml + ROOT = Path(__file__).resolve().parents[2] @@ -26,6 +28,104 @@ "docker/dockerfile:1.7@sha256:" "a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e" ) +TUTORIAL_CACHE_STEP = "Cache source-under-test Cargo build" +TUTORIAL_CACHE_VALUE = ( + "registryctl-tutorial-${{ runner.os }}-" + "${{ hashFiles('docs/site/scripts/check-registryctl-tutorials.sh') }}-" + "${{ hashFiles('Cargo.lock') }}" +) +TUTORIAL_CACHE_KEYS = tuple( + f" {key}: {TUTORIAL_CACHE_VALUE}" + for key in ("key", "'key'", '"key"') +) +TUTORIAL_CACHE_KEY = TUTORIAL_CACHE_KEYS[0] +RELEASE_BUILDER_HANDOFF = 'release_builder_image="${default_builder_image}"' +RELEASE_BUILDER_CONSUMER = ' "${release_builder_image}" \\' +TUTORIAL_BUILDER_CONSUMER = '\t\t"$BUILDER_IMAGE" \\' +LIVE_JOURNEY_BUILDER = f" {RUST_BUILDER} \\" +LIVE_JOURNEY_POSTGRES_IMAGE = "postgres:16.13-alpine" +LIVE_JOURNEY_POSTGRES_ASSIGNMENT = ( + f'readonly POSTGRES_IMAGE="{LIVE_JOURNEY_POSTGRES_IMAGE}"' +) +LIVE_JOURNEY_POSTGRES_CONSUMER = ' "$POSTGRES_IMAGE" \\' +RELEASE_BUILDER_PREFIX = ( + "docker run --rm \\", + " --platform linux/amd64 \\", + ' --user "$(id -u):$(id -g)" \\', + ' --volume "${repo_root}:/workspace" \\', + ' --volume "${release_cargo_home}:/workspace/.cargo-home" \\', + ' --volume "${release_target_dir}:/workspace/target" \\', + " --workdir /workspace \\", + " --env CARGO_HOME=/workspace/.cargo-home \\", + " --env CARGO_TARGET_DIR=/workspace/target \\", + " --env CARGO_INCREMENTAL=0 \\", + ' --env CARGO_TERM_COLOR="${CARGO_TERM_COLOR:-always}" \\', + " --env HOME=/workspace \\", + ' --env RELEASE_TAG="${tag}" \\', + ' --env RELEASE_RUSTFLAGS="${release_rustflags}" \\', + RELEASE_BUILDER_CONSUMER, +) +TUTORIAL_BUILDER_PREFIX = ( + "\tdocker run --rm \\", + "\t\t--platform linux/amd64 \\", + '\t\t--user "$(id -u):$(id -g)" \\', + '\t\t--volume "$REPO_ROOT:/workspace" \\', + "\t\t--workdir /workspace \\", + "\t\t--env CARGO_HOME=/workspace/target/registryctl-tutorial-cargo-home \\", + "\t\t--env CARGO_TARGET_DIR=/workspace/target/registryctl-tutorial-linux-amd64 \\", + "\t\t--env CARGO_TERM_COLOR=always \\", + "\t\t--env HOME=/tmp/registryctl-tutorial-home \\", + TUTORIAL_BUILDER_CONSUMER, +) +LIVE_JOURNEY_BUILDER_PREFIX = ( + " docker run --rm \\", + " --add-host host.docker.internal:host-gateway \\", + ' --network "$network_name" \\', + " --network-alias rhai-runner \\", + ' --env-file "$rhai_test_env_file" \\', + ' --volume "$repository_root:/workspace" \\', + ' --volume "$certificate_input:/live-postgres-ca:ro" \\', + ' --volume "$HOME/.cargo/registry:/usr/local/cargo/registry" \\', + ' --volume "$HOME/.cargo/git:/usr/local/cargo/git" \\', + " --volume registry-relay-linux-target:/target \\", + " --workdir /workspace \\", + LIVE_JOURNEY_BUILDER, +) +LIVE_JOURNEY_POSTGRES_SETUP_PREFIX = ( + "docker run --rm \\", + " --user 0:0 \\", + ' --volume "$certificate_volume:/certificates" \\', + ' --volume "$certificate_input:/input:ro" \\', + LIVE_JOURNEY_POSTGRES_CONSUMER, + " sh -eu -c \\", +) +LIVE_JOURNEY_POSTGRES_SERVER_PREFIX = ( + "docker run --detach \\", + ' --name "$container_name" \\', + ' --env-file "$docker_env_file" \\', + " --publish 127.0.0.1::5432 \\", + ' --volume "$certificate_volume:/certificates:ro" \\', + LIVE_JOURNEY_POSTGRES_CONSUMER, + " -c ssl=on \\", +) +RELEASE_BUILDER_TAIL = "\n".join( + ( + *RELEASE_BUILDER_PREFIX[-2:], + " bash -c 'set -euo pipefail", + ) +) +TUTORIAL_BUILDER_TAIL = "\n".join( + ( + *TUTORIAL_BUILDER_PREFIX[-2:], + "\t\tbash -c 'set -euo pipefail", + ) +) +LIVE_JOURNEY_BUILDER_TAIL = "\n".join( + ( + *LIVE_JOURNEY_BUILDER_PREFIX[-2:], + " sh -eu -c \\", + ) +) DOCKERFILES = ( Path("crates/registry-relay/Dockerfile"), @@ -34,17 +134,101 @@ Path("release/docker/Dockerfile.registry-notary"), Path("release/docker/Dockerfile.registry-relay"), ) +NOTARY_POSTGRES_CONFORMANCE_SCRIPT = Path( + "products/notary/scripts/postgresql-conformance.sh" +) # These are the maintained image and image-policy surfaces. Historical release # notes are immutable evidence and intentionally are not rewritten by this gate. MAINTAINED_TEXT_PATHS = DOCKERFILES + ( + Path(".github/workflows/ci.yml"), Path(".github/workflows/release.yml"), Path("release/scripts/build-release-binaries.sh"), + Path("docs/site/scripts/check-registryctl-tutorials.sh"), Path("crates/registry-relay/docs/ops.md"), Path("crates/registry-relay/docs/security-assurance.md"), Path("crates/registry-relay/scripts/check_docker_build_contract.py"), Path("crates/registry-relay/scripts/run-live-consultation-journey.sh"), Path("products/notary/docs/security-assurance.md"), + NOTARY_POSTGRES_CONFORMANCE_SCRIPT, +) + +NOTARY_POSTGRES_WORKFLOW_SOURCE_IMAGES = ( + "postgres:16.13-alpine", + "postgres:17.9-alpine", + "postgres:18.3-alpine", +) +NOTARY_POSTGRES_WORKFLOW_TARGET_IMAGES = ( + "postgres:16.14-alpine", + "postgres:17.10-alpine", + "postgres:18.4-alpine", +) +NOTARY_POSTGRES_WORKFLOW_RESTORE_IMAGES = ( + "postgres:17.10-alpine", + "postgres:18.4-alpine", +) +NOTARY_POSTGRES_WORKFLOW_IMAGES_BY_ROLE = { + "source_image": NOTARY_POSTGRES_WORKFLOW_SOURCE_IMAGES, + "target_image": NOTARY_POSTGRES_WORKFLOW_TARGET_IMAGES, + "restore_image": NOTARY_POSTGRES_WORKFLOW_RESTORE_IMAGES, +} +NOTARY_POSTGRES_WORKFLOW_RATIONALE = ( + "External Alpine PostgreSQL migration conformance, not a " + "project-owned Debian image." +) +WORKFLOW_IMAGE_ALLOWLIST = { + ( + Path(".github/workflows/relay-postgres-conformance.yml"), + "image", + "postgres:${{ matrix.postgresql }}-alpine", + ): ( + "External Alpine PostgreSQL state-plane conformance, not a " + "project-owned Debian image." + ), + **{ + ( + Path(".github/workflows/notary-postgres-conformance.yml"), + role, + image, + ): NOTARY_POSTGRES_WORKFLOW_RATIONALE + for role, images in NOTARY_POSTGRES_WORKFLOW_IMAGES_BY_ROLE.items() + for image in images + }, +} +WORKFLOW_IMAGE_KEYS = frozenset( + ("image", "source_image", "target_image", "restore_image") +) +WORKFLOW_DEFAULT_IMAGE_KEYS = frozenset( + ("default_source_image", "default_target_image", "default_restore_image") +) +NOTARY_POSTGRES_IMAGE_ASSIGNMENTS = ( + ("default_source_image", '"postgres:16.13-alpine"'), + ("default_target_image", '"postgres:16.14-alpine"'), + ("default_restore_image", '"postgres:17.10-alpine"'), + ("default_source_image", '"postgres:17.9-alpine"'), + ("default_target_image", '"postgres:17.10-alpine"'), + ("default_restore_image", '"postgres:18.4-alpine"'), + ("default_source_image", '"postgres:18.3-alpine"'), + ("default_target_image", '"postgres:18.4-alpine"'), + ("default_restore_image", '"postgres:18.4-alpine"'), + ("unsupported_postgres_image", '"postgres:15.18-alpine"'), +) +NOTARY_POSTGRES_LITERAL_LINES = tuple( + f"{name}={value}" for name, value in NOTARY_POSTGRES_IMAGE_ASSIGNMENTS +) +NOTARY_POSTGRES_FALLBACK_BINDINGS = ( + ( + "source_image", + '"${NOTARY_POSTGRES_SOURCE_IMAGE:-${default_source_image}}"', + ), + ( + "target_image", + '"${NOTARY_POSTGRES_TARGET_IMAGE:-${default_target_image}}"', + ), + ( + "restore_image", + '"${NOTARY_POSTGRES_RESTORE_IMAGE:-${default_restore_image}}"', + ), ) RUST_BUILDER_DOCKERFILES = DOCKERFILES[:3] @@ -58,9 +242,266 @@ Path("products/notary/Dockerfile"), Path("release/docker/Dockerfile.registry-notary"), ) +DOCKERFILE_NAMED_CONTEXTS = { + Path("crates/registry-relay/Dockerfile"): frozenset( + ("registry-platform", "registry-manifest", "crosswalk") + ), + Path("crates/registry-relay/Dockerfile.demo"): frozenset( + ("registry-platform",) + ), + Path("products/notary/Dockerfile"): frozenset( + ("registry-platform", "crosswalk") + ), + Path("release/docker/Dockerfile.registry-notary"): frozenset(), + Path("release/docker/Dockerfile.registry-relay"): frozenset(), +} +RELAY_FINAL_STAGE_COPY_INSTRUCTIONS = ( + "COPY --from=builder --chown=65532:65532 /workspace/runtime-root/ /", + ( + "COPY --from=builder /usr/local/bin/registry-relay " + "/usr/local/bin/registry-relay" + ), + ( + "COPY --from=builder /usr/local/bin/registry-relay-rhai-worker " + "/usr/local/bin/registry-relay-rhai-worker" + ), + "COPY LICENSE /licenses/registry-relay/LICENSE", +) +FINAL_STAGE_COPY_INSTRUCTIONS = { + Path("crates/registry-relay/Dockerfile"): RELAY_FINAL_STAGE_COPY_INSTRUCTIONS, + Path("crates/registry-relay/Dockerfile.demo"): RELAY_FINAL_STAGE_COPY_INSTRUCTIONS, + Path("products/notary/Dockerfile"): ( + "COPY --from=builder --chown=65532:65532 /workspace/runtime-root/ /", + "COPY --from=builder /workspace/out/ /usr/local/bin/", + ), + Path("release/docker/Dockerfile.registry-notary"): ( + "COPY --from=runtime-root /workspace/runtime-root/ /", + ), + Path("release/docker/Dockerfile.registry-relay"): ( + "COPY --from=runtime-root /workspace/runtime-root/ /", + ), +} +DOCKERFILE_RUN_INSTRUCTIONS = { + Path("crates/registry-relay/Dockerfile"): ( + ( + "RUN --mount=type=cache,target=/usr/local/cargo/registry " + "--mount=type=cache,target=/workspace/registry_relay/target " + "find src benches resources -type f -exec touch {} + && " + 'if [ -n "$REGISTRY_RELAY_FEATURES" ]; then ' + "cargo build --release --locked --features " + '"$REGISTRY_RELAY_FEATURES"; ' + "else cargo build --release --locked; fi && " + "cp /workspace/registry_relay/target/release/registry-relay " + "/usr/local/bin/registry-relay && " + "cp /workspace/registry_relay/target/release/" + "registry-relay-rhai-worker " + "/usr/local/bin/registry-relay-rhai-worker && " + "mkdir -p /workspace/runtime-root/etc/registry-relay " + "/workspace/runtime-root/var/lib/registry-relay/cache " + "/workspace/runtime-root/var/lib/registry-relay/data " + "/workspace/runtime-root/var/log/registry-relay && " + "chown -R 65532:65532 /workspace/runtime-root" + ), + ), + Path("crates/registry-relay/Dockerfile.demo"): ( + ( + "RUN --mount=type=cache,target=/usr/local/cargo/registry " + "--mount=type=cache,target=/workspace/registry_relay/target " + "cargo build --release --locked --features " + "spdci-api-standards,standards-cel-mapping," + "attribute-release && " + "cp /workspace/registry_relay/target/release/registry-relay " + "/usr/local/bin/registry-relay && " + "cp /workspace/registry_relay/target/release/" + "registry-relay-rhai-worker " + "/usr/local/bin/registry-relay-rhai-worker && " + "mkdir -p /workspace/runtime-root/etc/registry-relay " + "/workspace/runtime-root/var/lib/registry-relay/cache " + "/workspace/runtime-root/var/lib/registry-relay/data " + "/workspace/runtime-root/var/log/registry-relay && " + "chown -R 65532:65532 /workspace/runtime-root" + ), + ), + Path("products/notary/Dockerfile"): ( + ( + "RUN --mount=type=cache,target=/usr/local/cargo/registry " + "--mount=type=cache,target=/workspace/target " + 'if [ -n "$REGISTRY_NOTARY_FEATURES" ]; then ' + "CARGO_TARGET_DIR=/workspace/target cargo build --release " + "--locked -p registry-notary --features " + '"$REGISTRY_NOTARY_FEATURES"; ' + "else CARGO_TARGET_DIR=/workspace/target cargo build --release " + "--locked -p registry-notary; fi && " + "mkdir -p /workspace/out && " + "cp /workspace/target/release/registry-notary " + "/workspace/out/registry-notary && " + 'case ",$REGISTRY_NOTARY_FEATURES," in ' + "*,registry-notary-cel,*) " + "CARGO_TARGET_DIR=/workspace/target cargo build --release " + "--locked -p registry-notary-server " + "--bin registry-notary-cel-worker --features " + '"$REGISTRY_NOTARY_FEATURES" && ' + "cp /workspace/target/release/registry-notary-cel-worker " + "/workspace/out/registry-notary-cel-worker ;; " + "*) true ;; esac && " + "mkdir -p /workspace/runtime-root/etc/registry-notary " + "/workspace/runtime-root/var/lib/registry-notary " + "/workspace/runtime-root/var/log/registry-notary && " + "chown -R 65532:65532 /workspace/runtime-root" + ), + ), + Path("release/docker/Dockerfile.registry-notary"): ( + ( + "RUN --mount=type=bind,source=dist/image-bin," + "target=/workspace/image-bin " + "mkdir -p /workspace/runtime-root/etc/registry-notary " + "/workspace/runtime-root/usr/local/bin " + "/workspace/runtime-root/var/lib/registry-notary " + "/workspace/runtime-root/var/log/registry-notary && " + "install -m 0755 /workspace/image-bin/registry-notary " + "/workspace/runtime-root/usr/local/bin/registry-notary && " + "install -m 0755 " + "/workspace/image-bin/registry-notary-cel-worker " + "/workspace/runtime-root/usr/local/bin/" + "registry-notary-cel-worker && " + "chown -R 65532:65532 " + "/workspace/runtime-root/etc/registry-notary " + "/workspace/runtime-root/var/lib/registry-notary " + "/workspace/runtime-root/var/log/registry-notary && " + 'find /workspace/runtime-root -exec touch -h ' + '--date="@${SOURCE_DATE_EPOCH}" {} +' + ), + ), + Path("release/docker/Dockerfile.registry-relay"): ( + ( + "RUN --mount=type=bind,source=dist/image-bin," + "target=/workspace/image-bin " + "--mount=type=bind,source=LICENSE,target=/workspace/LICENSE " + "mkdir -p /workspace/runtime-root/etc/registry-relay " + "/workspace/runtime-root/licenses/registry-relay " + "/workspace/runtime-root/usr/local/bin " + "/workspace/runtime-root/var/lib/registry-relay/cache " + "/workspace/runtime-root/var/lib/registry-relay/data " + "/workspace/runtime-root/var/log/registry-relay && " + "install -m 0755 /workspace/image-bin/registry-relay " + "/workspace/runtime-root/usr/local/bin/registry-relay && " + "install -m 0755 " + "/workspace/image-bin/registry-relay-rhai-worker " + "/workspace/runtime-root/usr/local/bin/" + "registry-relay-rhai-worker && " + "install -m 0644 /workspace/LICENSE " + "/workspace/runtime-root/licenses/registry-relay/LICENSE && " + "chown -R 65532:65532 " + "/workspace/runtime-root/etc/registry-relay " + "/workspace/runtime-root/var/lib/registry-relay " + "/workspace/runtime-root/var/log/registry-relay && " + 'find /workspace/runtime-root -exec touch -h ' + '--date="@${SOURCE_DATE_EPOCH}" {} +' + ), + ), +} -FROM_RE = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", re.MULTILINE) +RELAY_RUNTIME_DIRECTIVES = ( + ( + "HEALTHCHECK --interval=30s --timeout=5s --start-period=10s " + '--retries=3 CMD ["/usr/local/bin/registry-relay", "healthcheck"]', + "binary healthcheck", + ), + ( + 'ENTRYPOINT ["/usr/local/bin/registry-relay"]', + "absolute Relay entrypoint", + ), + ("WORKDIR /var/lib/registry-relay", "Relay working directory"), + ( + 'CMD ["--config", "/etc/registry-relay/config.yaml"]', + "Relay default command", + ), +) +NOTARY_RUNTIME_DIRECTIVES = ( + ( + "HEALTHCHECK --interval=30s --timeout=5s --start-period=10s " + '--retries=3 CMD ["/usr/local/bin/registry-notary", "healthcheck"]', + "binary healthcheck", + ), + ( + 'ENTRYPOINT ["/usr/local/bin/registry-notary"]', + "absolute Notary entrypoint", + ), + ("WORKDIR /var/lib/registry-notary", "Notary working directory"), + ( + 'CMD ["--config", "/etc/registry-notary/config.yaml"]', + "Notary default command", + ), +) + +FROM_RE = re.compile( + r"^[ \t]*FROM[ \t]+(?:--platform=(?P\S+)[ \t]+)?" + r"(?P[^\s#]+)" + r"(?:[ \t]+AS[ \t]+(?P[^\s#]+))?" + r"[ \t]*(?:#.*)?$", + re.IGNORECASE | re.MULTILINE, +) +DOCKERFILE_PARSER_DIRECTIVE_RE = re.compile( + r"^[ \t]*#[ \t]*(?P[A-Za-z]+)[ \t]*=" + r"[ \t]*(?P\S+)[ \t]*$", + re.IGNORECASE, +) +COPY_INSTRUCTION_RE = re.compile( + r"^[ \t]*COPY[ \t]+(?P.*)$", + re.IGNORECASE, +) +COPY_OPTION_NAMES = frozenset(("from", "chown")) +COPY_OPTION_RE = re.compile( + r"--(?P[a-z][a-z0-9-]*)=(?P[^\s\\\"']+)" +) +RUN_INSTRUCTION_RE = re.compile( + r"^[ \t]*RUN[ \t]+(?P.*)$", + re.IGNORECASE, +) DIGEST_PIN_RE = re.compile(r"@sha256:[0-9a-f]{64}$") +RETIRED_MARKER_RE = re.compile( + r"\b(?:bookworm|debian[ \t_:-]*v?[ \t_:-]*12)\b", + re.IGNORECASE, +) +RUNTIME_DIRECTIVE_RE = re.compile( + r"^[ \t]*(?PHEALTHCHECK|ENTRYPOINT|WORKDIR|CMD|USER|VOLUME)" + r"(?:[ \t]+|$)", + re.IGNORECASE, +) +RELEASE_BUILDER_KEY_RE = re.compile( + r"^[ \t]*RELEASE_BUILDER_IMAGE[ \t]*:" +) +DEFAULT_BUILDER_ASSIGNMENT_RE = re.compile( + r"^[ \t]*(?:(?:export|readonly)[ \t]+)?default_builder_image[ \t]*=" +) +TUTORIAL_BUILDER_ASSIGNMENT_RE = re.compile( + r"^[ \t]*(?:(?:export|readonly)[ \t]+)?BUILDER_IMAGE[ \t]*=" +) +RELEASE_BUILDER_HANDOFF_RE = re.compile( + r"^[ \t]*(?:(?:export|readonly)[ \t]+)?release_builder_image[ \t]*=" +) +LIVE_JOURNEY_BUILDER_RE = re.compile( + r"^[ \t]+rust:[^ \t#]+[ \t]+\\[ \t]*$" +) +LIVE_JOURNEY_POSTGRES_ASSIGNMENT_RE = re.compile( + r"^[ \t]*(?:(?:export|readonly)[ \t]+)?POSTGRES_IMAGE[ \t]*=" +) +CACHE_KEY_RE = re.compile( + r"""^[ \t]*(?:key|'key'|"key")[ \t]*:""" +) +RESTORE_KEYS_RE = re.compile( + r"""^[ \t]*(?:restore-keys|'restore-keys'|"restore-keys")[ \t]*:""", + re.MULTILINE, +) +NOTARY_POSTGRES_IMAGE_ASSIGNMENT_RE = re.compile( + r"^[ \t]*(?Pdefault_source_image|default_target_image|" + r"default_restore_image|unsupported_postgres_image)[ \t]*=" + r"(?P.*)$" +) +NOTARY_POSTGRES_FALLBACK_RE = re.compile( + r"^[ \t]*(?Psource_image|target_image|restore_image)[ \t]*=" + r"(?P.*)$" +) def read(root: Path, relative: Path, failures: list[str]) -> str: @@ -72,6 +513,217 @@ def read(root: Path, relative: Path, failures: list[str]) -> str: return "" +def discover_workflow_paths(root: Path) -> tuple[Path, ...]: + directory = root / ".github" / "workflows" + if not directory.is_dir(): + return () + return tuple( + path.relative_to(root) + for path in sorted( + ( + path + for pattern in ("*.yml", "*.yaml") + for path in directory.glob(pattern) + if path.is_file() + ), + key=lambda path: path.name, + ) + ) + + +def normalize_dockerfile_instructions( + text: str, + relative: Path, + failures: list[str], +) -> tuple[str, ...]: + lines = text.splitlines() + escape_directives = [] + for line in lines: + if not line.strip(): + break + match = DOCKERFILE_PARSER_DIRECTIVE_RE.match(line) + if match is None: + break + key = match.group("key").casefold() + if key not in {"syntax", "escape", "check"}: + break + if key == "escape": + escape_directives.append(match.group("value")) + if len(escape_directives) > 1 or ( + escape_directives + and escape_directives[0] != "\\" + ): + failures.append( + f"{relative}: unsupported Dockerfile escape directive prevents " + "bounded instruction inspection" + ) + return () + + instructions = [] + index = 0 + while index < len(lines): + line = lines[index] + index += 1 + if not line.strip() or line.lstrip().startswith("#"): + continue + + parts: list[str] = [] + while True: + continued = line.rstrip().endswith("\\") + part = line.rstrip() + if continued: + part = part[:-1] + parts.append(part) + if not continued: + break + + while index < len(lines): + line = lines[index] + index += 1 + if line.strip() and not line.lstrip().startswith("#"): + break + else: + failures.append( + f"{relative}: unterminated Dockerfile line continuation " + "prevents bounded instruction inspection" + ) + return () + + instructions.append("".join(parts)) + return tuple(instructions) + + +def collect_dockerfile_copy_sources( + instructions: tuple[str, ...], + relative: Path, + failures: list[str], +) -> list[str]: + sources = [] + for instruction in instructions: + instruction_match = COPY_INSTRUCTION_RE.match(instruction) + if instruction_match is None: + continue + tokens = instruction_match.group("arguments").split() + seen_options = set() + while tokens and tokens[0].startswith("--"): + option_match = COPY_OPTION_RE.fullmatch(tokens.pop(0)) + if option_match is None: + failures.append( + f"{relative}: unsupported COPY option syntax prevents " + "bounded source inspection" + ) + return [] + name = option_match.group("name") + if name not in COPY_OPTION_NAMES or name in seen_options: + failures.append( + f"{relative}: unsupported COPY option syntax prevents " + "bounded source inspection" + ) + return [] + seen_options.add(name) + if name == "from": + sources.append(option_match.group("value")) + + if not tokens or tokens[0].startswith(("-", "'", '"', "\\")): + failures.append( + f"{relative}: unsupported COPY operand prefix prevents " + "bounded source inspection" + ) + return [] + return sources + + +def collect_workflow_image_references( + text: str, + relative: Path, + failures: list[str], +) -> list[tuple[str, str, str]]: + try: + document = yaml.safe_load(text) + except yaml.YAMLError as error: + failures.append( + f"{relative}: workflow YAML parse failed: {type(error).__name__}" + ) + return [] + if not isinstance(document, dict): + failures.append( + f"{relative}: workflow YAML root must be a mapping, " + f"found {type(document).__name__}" + ) + return [] + + references: list[tuple[str, str, str]] = [] + active_nodes: set[int] = set() + + def add_image(value: object, role: str, location: str) -> None: + if not isinstance(value, str) or not value.strip(): + failures.append( + f"{relative}: unsupported workflow image value at {location}: " + f"expected a non-empty string, found {type(value).__name__}" + ) + return + references.append((role, location, value)) + + def visit(value: object, location: str) -> None: + if not isinstance(value, (dict, list)): + return + identity = id(value) + if identity in active_nodes: + failures.append( + f"{relative}: unsupported recursive YAML alias at {location}" + ) + return + active_nodes.add(identity) + if isinstance(value, dict): + for key, child in value.items(): + child_location = f"{location}.{key}" + if key == "container": + if isinstance(child, str): + add_image(child, "container", child_location) + elif isinstance(child, dict) and "image" in child: + add_image( + child["image"], + "container", + f"{child_location}.image", + ) + else: + failures.append( + f"{relative}: unsupported workflow image value at " + f"{child_location}: container must be a non-empty " + "string or a mapping with an image key" + ) + if isinstance(child, dict): + for nested_key, nested_child in child.items(): + if nested_key != "image": + visit( + nested_child, + f"{child_location}.{nested_key}", + ) + elif isinstance(child, list): + visit(child, child_location) + continue + elif key in WORKFLOW_IMAGE_KEYS or key in WORKFLOW_DEFAULT_IMAGE_KEYS: + add_image(child, str(key), child_location) + elif ( + key == "uses" + and isinstance(child, str) + and child.startswith("docker://") + ): + add_image( + child.removeprefix("docker://"), + "uses", + child_location, + ) + visit(child, child_location) + else: + for index, child in enumerate(value): + visit(child, f"{location}[{index}]") + active_nodes.remove(identity) + + visit(document, "$") + return references + + def require( text: str, needle: str, @@ -83,69 +735,297 @@ def require( failures.append(f"{relative}: missing {detail}: {needle!r}") -def runtime_stage(text: str) -> str: - marker = f"FROM {DISTROLESS_RUNTIME} AS runtime" - offset = text.find(marker) - return text[offset:] if offset >= 0 else "" +def require_unique_active_line( + text: str, + allowed_lines: tuple[str, ...], + active_pattern: re.Pattern[str], + relative: Path, + detail: str, + failures: list[str], +) -> None: + active_lines = [ + candidate + for candidate in text.splitlines() + if active_pattern.match(candidate) + ] + if len(active_lines) != 1 or active_lines[0] not in allowed_lines: + failures.append( + f"{relative}: missing {detail}: expected exactly one active " + f"line from {allowed_lines!r}; found {active_lines!r}" + ) + + +def require_unique_text( + text: str, + expected: str, + relative: Path, + detail: str, + failures: list[str], +) -> None: + count = text.count(expected) + if count != 1: + failures.append( + f"{relative}: missing {detail}: expected exactly one exact " + f"block {expected!r}; found {count}" + ) + + +def require_exact_command_prefix( + command: str, + expected_lines: tuple[str, ...], + relative: Path, + detail: str, + failures: list[str], + *, + report_values: bool = True, +) -> None: + actual_lines = tuple(command.splitlines()[: len(expected_lines)]) + if actual_lines != expected_lines: + failure = ( + f"{relative}: {detail} does not match the exact expected " + "header/options/image prefix" + ) + if report_values: + failure += f": expected {expected_lines!r}; found {actual_lines!r}" + failures.append(failure) + + +def workflow_step(text: str, name: str) -> str: + lines = text.splitlines() + header = f" - name: {name}" + matches = [index for index, line in enumerate(lines) if line == header] + if len(matches) != 1: + return "" + start = matches[0] + end = next( + ( + index + for index in range(start + 1, len(lines)) + if lines[index].startswith(" - ") + ), + len(lines), + ) + return "\n".join(lines[start:end]) + + +def shell_continuation_command( + text: str, + command: str, + required_line: str | None = None, +) -> str: + lines = text.splitlines() + starts = [ + index + for index, line in enumerate(lines) + if line.lstrip() == f"{command} \\" + ] + commands = [] + for start in starts: + end = start + 1 + while end < len(lines) and lines[end - 1].rstrip().endswith("\\"): + end += 1 + commands.append("\n".join(lines[start:end])) + if required_line is not None: + commands = [ + candidate + for candidate in commands + if required_line in candidate.splitlines() + ] + return commands[0] if len(commands) == 1 else "" def check_repository(root: Path = ROOT) -> list[str]: failures: list[str] = [] + workflow_paths = discover_workflow_paths(root) + maintained_paths = tuple( + dict.fromkeys((*MAINTAINED_TEXT_PATHS, *workflow_paths)) + ) texts = { relative: read(root, relative, failures) - for relative in MAINTAINED_TEXT_PATHS + for relative in maintained_paths } - retired_markers = ("book" + "worm", "debian" + "12") for relative, text in texts.items(): - lowered = text.casefold() - for marker in retired_markers: - if marker in lowered: + marker = RETIRED_MARKER_RE.search(text) + if marker: + failures.append( + f"{relative}: retired Debian image generation marker remains: " + f"{marker.group(0).casefold()}" + ) + + for relative in workflow_paths: + references = collect_workflow_image_references( + texts[relative], + relative, + failures, + ) + for role, location, value in references: + if (relative, role, value) not in WORKFLOW_IMAGE_ALLOWLIST: failures.append( - f"{relative}: retired Debian image generation marker remains: {marker}" + f"{relative}: workflow image reference is not allowlisted " + f"at {location}" ) + notary_postgres = texts[NOTARY_POSTGRES_CONFORMANCE_SCRIPT] + notary_postgres_assignments = tuple( + (match.group("name"), match.group("value")) + for line in notary_postgres.splitlines() + if (match := NOTARY_POSTGRES_IMAGE_ASSIGNMENT_RE.match(line)) + ) + if notary_postgres_assignments != NOTARY_POSTGRES_IMAGE_ASSIGNMENTS: + failures.append( + f"{NOTARY_POSTGRES_CONFORMANCE_SCRIPT}: PostgreSQL image " + "assignments must match the exact ordered reviewed inventory" + ) + notary_postgres_literal_lines = tuple( + line.strip() + for line in notary_postgres.splitlines() + if not line.lstrip().startswith("#") and "postgres:" in line + ) + if notary_postgres_literal_lines != NOTARY_POSTGRES_LITERAL_LINES: + failures.append( + f"{NOTARY_POSTGRES_CONFORMANCE_SCRIPT}: direct PostgreSQL image " + "literals must match the exact ordered reviewed assignment lines" + ) + notary_postgres_fallbacks = tuple( + (match.group("name"), match.group("value")) + for line in notary_postgres.splitlines() + if (match := NOTARY_POSTGRES_FALLBACK_RE.match(line)) + ) + if notary_postgres_fallbacks != NOTARY_POSTGRES_FALLBACK_BINDINGS: + failures.append( + f"{NOTARY_POSTGRES_CONFORMANCE_SCRIPT}: PostgreSQL environment " + "fallbacks must match the exact ordered reviewed bindings" + ) + for relative in DOCKERFILES: text = texts[relative] - bases = FROM_RE.findall(text) - if not bases: + instructions = normalize_dockerfile_instructions( + text, + relative, + failures, + ) + run_instructions = tuple( + " ".join(instruction.split()) + for instruction in instructions + if RUN_INSTRUCTION_RE.match(instruction) + ) + if run_instructions != DOCKERFILE_RUN_INSTRUCTIONS[relative]: + failures.append( + f"{relative}: RUN instructions must match the exact " + "reviewed inventory" + ) + stage_matches = list(FROM_RE.finditer(text)) + if not stage_matches: failures.append(f"{relative}: no FROM instruction found") continue - for base in bases: + stages = tuple( + ( + match.group("base"), + match.group("alias"), + match.group("platform"), + ) + for match in stage_matches + ) + expected_stages = ( + ( + (RUST_BUILDER, "builder", None), + (DISTROLESS_RUNTIME, "runtime", None), + ) + if relative in RUST_BUILDER_DOCKERFILES + else ( + (DEBIAN_PREPARATION, "runtime-root", None), + (DISTROLESS_RUNTIME, "runtime", None), + ) + ) + if stages != expected_stages: + failures.append( + f"{relative}: Dockerfile stage sequence must be exactly " + f"{expected_stages!r}; found {stages!r}" + ) + stage_aliases = { + alias.casefold() + for _base, alias, _platform in stages + if alias is not None + } + named_contexts = DOCKERFILE_NAMED_CONTEXTS[relative] + for source in collect_dockerfile_copy_sources( + instructions, + relative, + failures, + ): + if ( + source.casefold() not in stage_aliases + and source not in named_contexts + ): + failures.append( + f"{relative}: COPY --from source is not a declared stage " + "or reviewed named build context" + ) + final_from_indexes = [ + index + for index, instruction in enumerate(instructions) + if FROM_RE.fullmatch(instruction) + ] + if final_from_indexes: + final_copy_instructions = tuple( + " ".join(instruction.split()) + for instruction in instructions[final_from_indexes[-1] + 1 :] + if COPY_INSTRUCTION_RE.match(instruction) + ) + if ( + final_copy_instructions + != FINAL_STAGE_COPY_INSTRUCTIONS[relative] + ): + failures.append( + f"{relative}: final-stage COPY instructions must match " + "the exact reviewed inventory" + ) + for base, _alias, _platform in stages: if not DIGEST_PIN_RE.search(base): failures.append( f"{relative}: upstream base is not pinned by immutable digest: {base}" ) - - require( - text, - f"FROM {DISTROLESS_RUNTIME} AS runtime", - relative, - "Distroless Debian 13 non-root final runtime", - failures, - ) - runtime = runtime_stage(text) - for forbidden in ("\nRUN ", "apt-get", "/bin/sh", "curl ", "wget "): + runtime = text[stage_matches[-1].start() :] + if re.search(r"^[ \t]*RUN(?:[ \t]|$)", runtime, re.IGNORECASE | re.MULTILINE): + failures.append( + f"{relative}: final Distroless runtime contains 'RUN'" + ) + for forbidden in ("apt-get", "/bin/sh", "curl ", "wget "): if forbidden in runtime: failures.append( f"{relative}: final Distroless runtime contains {forbidden.strip()!r}" ) - require( - runtime, - "HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3", - relative, - "binary healthcheck", - failures, - ) - - for relative in RUST_BUILDER_DOCKERFILES: - require( - texts[relative], - f"FROM {RUST_BUILDER} AS builder", - relative, - "pinned Debian 13 Rust builder", - failures, + runtime_directives = ( + RELAY_RUNTIME_DIRECTIVES + if relative in RELAY_DOCKERFILES + else NOTARY_RUNTIME_DIRECTIVES ) + active_runtime_directives: dict[str, list[str]] = {} + for line in runtime.splitlines(): + directive_match = RUNTIME_DIRECTIVE_RE.match(line) + if directive_match: + name = directive_match.group("name").casefold() + active_runtime_directives.setdefault(name, []).append(line) + for directive, detail in runtime_directives: + name = directive.partition(" ")[0] + active_lines = active_runtime_directives.get(name.casefold(), []) + if active_lines != [directive]: + failures.append( + f"{relative}: missing {detail} in final runtime stage: " + f"expected exactly one active {name} directive " + f"{directive!r}; found {active_lines!r}" + ) + for name, invariant in ( + ("user", "inherit the nonroot base user"), + ("volume", "declare no writable VOLUME mount surfaces"), + ): + active_lines = active_runtime_directives.get(name, []) + if active_lines: + failures.append( + f"{relative}: final Distroless runtime must {invariant}; " + f"found active {name.upper()} directives {active_lines!r}" + ) for relative in PREPARATION_DOCKERFILES: text = texts[relative] @@ -153,13 +1033,6 @@ def check_repository(root: Path = ROOT) -> list[str]: failures.append( f"{relative}: pinned Dockerfile frontend must be the first line" ) - require( - text, - f"FROM {DEBIAN_PREPARATION} AS runtime-root", - relative, - "pinned Debian 13 runtime preparation base", - failures, - ) require( text, "ARG SOURCE_DATE_EPOCH=0", @@ -191,13 +1064,6 @@ def check_repository(root: Path = ROOT) -> list[str]: "Relay worker binary", failures, ) - require( - runtime_stage(text), - 'ENTRYPOINT ["/usr/local/bin/registry-relay"]', - relative, - "absolute Relay entrypoint", - failures, - ) product_notary = texts[Path("products/notary/Dockerfile")] require( @@ -216,13 +1082,6 @@ def check_repository(root: Path = ROOT) -> list[str]: "Notary CEL worker binary", failures, ) - require( - runtime_stage(text), - 'ENTRYPOINT ["/usr/local/bin/registry-notary"]', - relative, - "absolute Notary entrypoint", - failures, - ) require( text, "chown -R 65532:65532", @@ -230,13 +1089,6 @@ def check_repository(root: Path = ROOT) -> list[str]: "numeric nonroot-owned Notary runtime directories", failures, ) - require( - runtime_stage(text), - "WORKDIR /var/lib/registry-notary", - relative, - "Notary working directory", - failures, - ) if re.search( r"^\s*(?:COPY|ADD)\b[^\n]*(?:\.so\b|pkcs11[^/\s]*module)", text, @@ -247,14 +1099,57 @@ def check_repository(root: Path = ROOT) -> list[str]: ) workflow = texts[Path(".github/workflows/release.yml")] + ci_workflow = texts[Path(".github/workflows/ci.yml")] binary_recipe = texts[Path("release/scripts/build-release-binaries.sh")] - require( + tutorial_check = texts[Path("docs/site/scripts/check-registryctl-tutorials.sh")] + require_unique_active_line( workflow, - f"RELEASE_BUILDER_IMAGE: {RUST_BUILDER}", + (f" RELEASE_BUILDER_IMAGE: {RUST_BUILDER}",), + RELEASE_BUILDER_KEY_RE, Path(".github/workflows/release.yml"), "pinned Debian 13 release builder", failures, ) + require_unique_active_line( + binary_recipe, + tuple( + f'{prefix}default_builder_image="{RUST_BUILDER}"' + for prefix in ("", "readonly ", "export ") + ), + DEFAULT_BUILDER_ASSIGNMENT_RE, + Path("release/scripts/build-release-binaries.sh"), + "pinned Debian 13 release recipe builder", + failures, + ) + require_unique_active_line( + binary_recipe, + tuple( + f"{prefix}{RELEASE_BUILDER_HANDOFF}" + for prefix in ("", "readonly ", "export ") + ), + RELEASE_BUILDER_HANDOFF_RE, + Path("release/scripts/build-release-binaries.sh"), + "release builder handoff", + failures, + ) + release_builder_command = shell_continuation_command( + binary_recipe, + "docker run --rm", + ) + require_unique_text( + release_builder_command, + RELEASE_BUILDER_TAIL, + Path("release/scripts/build-release-binaries.sh"), + "release Docker builder command tail", + failures, + ) + require_exact_command_prefix( + release_builder_command, + RELEASE_BUILDER_PREFIX, + Path("release/scripts/build-release-binaries.sh"), + "release Docker builder command", + failures, + ) require( binary_recipe, "--features registry-notary/registry-notary-cel,registry-notary/pkcs11", @@ -262,17 +1157,125 @@ def check_repository(root: Path = ROOT) -> list[str]: "PKCS#11-enabled release build", failures, ) + require_unique_active_line( + tutorial_check, + tuple( + f'{prefix}BUILDER_IMAGE="{RUST_BUILDER}"' + for prefix in ("", "readonly ", "export ") + ), + TUTORIAL_BUILDER_ASSIGNMENT_RE, + Path("docs/site/scripts/check-registryctl-tutorials.sh"), + "pinned Debian 13 registryctl tutorial builder", + failures, + ) + tutorial_builder_command = shell_continuation_command( + tutorial_check, + "docker run --rm", + ) + require_unique_text( + tutorial_builder_command, + TUTORIAL_BUILDER_TAIL, + Path("docs/site/scripts/check-registryctl-tutorials.sh"), + "registryctl tutorial Docker builder command tail", + failures, + ) + require_exact_command_prefix( + tutorial_builder_command, + TUTORIAL_BUILDER_PREFIX, + Path("docs/site/scripts/check-registryctl-tutorials.sh"), + "registryctl tutorial Docker builder command", + failures, + ) live_journey = texts[ Path("crates/registry-relay/scripts/run-live-consultation-journey.sh") ] - require( + live_journey_path = Path( + "crates/registry-relay/scripts/run-live-consultation-journey.sh" + ) + postgres_assignments = [ + line + for line in live_journey.splitlines() + if LIVE_JOURNEY_POSTGRES_ASSIGNMENT_RE.match(line) + ] + if postgres_assignments != [LIVE_JOURNEY_POSTGRES_ASSIGNMENT]: + failures.append( + f"{live_journey_path}: live-journey PostgreSQL image assignment " + "must remain the single reviewed value" + ) + postgres_setup_command = shell_continuation_command( + live_journey, + "docker run --rm", + LIVE_JOURNEY_POSTGRES_CONSUMER, + ) + require_exact_command_prefix( + postgres_setup_command, + LIVE_JOURNEY_POSTGRES_SETUP_PREFIX, + live_journey_path, + "live-journey PostgreSQL certificate setup command", + failures, + report_values=False, + ) + postgres_server_command = shell_continuation_command( + live_journey, + "docker run --detach", + LIVE_JOURNEY_POSTGRES_CONSUMER, + ) + require_exact_command_prefix( + postgres_server_command, + LIVE_JOURNEY_POSTGRES_SERVER_PREFIX, + live_journey_path, + "live-journey PostgreSQL server command", + failures, + report_values=False, + ) + require_unique_active_line( live_journey, - RUST_BUILDER, - Path("crates/registry-relay/scripts/run-live-consultation-journey.sh"), + (LIVE_JOURNEY_BUILDER,), + LIVE_JOURNEY_BUILDER_RE, + live_journey_path, "pinned Debian 13 live-journey builder", failures, ) + live_builder_command = shell_continuation_command( + live_journey, + "docker run --rm", + LIVE_JOURNEY_BUILDER, + ) + require_unique_text( + live_builder_command, + LIVE_JOURNEY_BUILDER_TAIL, + live_journey_path, + "live-journey Docker builder command tail", + failures, + ) + require_exact_command_prefix( + live_builder_command, + LIVE_JOURNEY_BUILDER_PREFIX, + live_journey_path, + "live-journey Docker builder command", + failures, + ) + + tutorial_cache = workflow_step(ci_workflow, TUTORIAL_CACHE_STEP) + if not tutorial_cache: + failures.append( + f".github/workflows/ci.yml: missing unique {TUTORIAL_CACHE_STEP!r} step" + ) + else: + require_unique_active_line( + tutorial_cache, + TUTORIAL_CACHE_KEYS, + CACHE_KEY_RE, + Path(".github/workflows/ci.yml"), + "registryctl tutorial builder cache key", + failures, + ) + if RESTORE_KEYS_RE.search(tutorial_cache): + failures.append( + ".github/workflows/ci.yml: registryctl tutorial builder cache " + "must not use restore-keys fallback" + ) return failures diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 3d4837d6..2b2d6ed8 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -32,6 +32,10 @@ "Other workflow path classification", ".github/workflows/*)\n mark_all\n ;;", ), + ( + "Debian 13 image contract", + "run: python3 release/scripts/check-debian13-images.py", + ), ("Cargo metadata", "run: cargo metadata --locked --format-version 1"), ( "Manifest profile validation", @@ -112,6 +116,10 @@ ("Relay OpenAPI command", "run: just openapi-contract"), ("Relay exposure check", "name: Relay exposure check"), ("Release helper tests", "run: python3 -m unittest release/scripts/test_registry_release.py"), + ( + "Debian 13 image contract checker tests", + "run: python3 -m unittest release/scripts/test_check_debian13_images.py", + ), ( "Release planning command tests", "run: python3 -m unittest release/scripts/test_registry_release_plans.py", diff --git a/release/scripts/test_check_debian13_images.py b/release/scripts/test_check_debian13_images.py new file mode 100644 index 00000000..8c50785f --- /dev/null +++ b/release/scripts/test_check_debian13_images.py @@ -0,0 +1,1853 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import shutil +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "release/scripts/check-debian13-images.py" +TUTORIAL_CHECK = Path("docs/site/scripts/check-registryctl-tutorials.sh") +RELEASE_WORKFLOW = Path(".github/workflows/release.yml") +RELEASE_BINARY_RECIPE = Path("release/scripts/build-release-binaries.sh") +LIVE_JOURNEY = Path( + "crates/registry-relay/scripts/run-live-consultation-journey.sh" +) +CI_WORKFLOW = Path(".github/workflows/ci.yml") +NOTARY_POSTGRES_WORKFLOW = Path( + ".github/workflows/notary-postgres-conformance.yml" +) +RELAY_POSTGRES_WORKFLOW = Path( + ".github/workflows/relay-postgres-conformance.yml" +) +NOTARY_POSTGRES_CONFORMANCE_SCRIPT = Path( + "products/notary/scripts/postgresql-conformance.sh" +) + +EXPECTED_SURFACES = { + CI_WORKFLOW, + RELEASE_WORKFLOW, + Path("crates/registry-relay/Dockerfile"), + Path("crates/registry-relay/Dockerfile.demo"), + Path("crates/registry-relay/docs/ops.md"), + Path("crates/registry-relay/docs/security-assurance.md"), + Path("crates/registry-relay/scripts/check_docker_build_contract.py"), + LIVE_JOURNEY, + TUTORIAL_CHECK, + Path("products/notary/Dockerfile"), + Path("products/notary/docs/security-assurance.md"), + NOTARY_POSTGRES_CONFORMANCE_SCRIPT, + Path("release/docker/Dockerfile.registry-notary"), + Path("release/docker/Dockerfile.registry-relay"), + RELEASE_BINARY_RECIPE, +} + + +def load_module(): + spec = importlib.util.spec_from_file_location("check_debian13_images", SCRIPT) + if spec is None or spec.loader is None: + raise ImportError(f"could not load module spec from {SCRIPT}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class Debian13ImageCheckTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.module = load_module() + + def fixture(self) -> Path: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + root = Path(temporary.name) + for relative in self.module.MAINTAINED_TEXT_PATHS: + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / relative, destination) + return root + + def write_workflow(self, root: Path, name: str, text: str) -> Path: + target = root / ".github" / "workflows" / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + return target + + def assert_has_failure( + self, + root: Path, + fragment: str, + ) -> None: + failures = self.module.check_repository(root) + self.assertTrue( + any(fragment in failure for failure in failures), + failures, + ) + + def runtime_directives(self, relative: Path) -> tuple[str, ...]: + product = "relay" if relative in self.module.RELAY_DOCKERFILES else "notary" + binary = f"/usr/local/bin/registry-{product}" + return ( + "HEALTHCHECK --interval=30s --timeout=5s " + f'--start-period=10s --retries=3 CMD ["{binary}", "healthcheck"]', + f'ENTRYPOINT ["{binary}"]', + f"WORKDIR /var/lib/registry-{product}", + f'CMD ["--config", "/etc/registry-{product}/config.yaml"]', + ) + + def notary_workflow_with_restore_images(self) -> str: + source = (ROOT / NOTARY_POSTGRES_WORKFLOW).read_text(encoding="utf-8") + additions = ( + ( + " target_image: postgres:16.14-alpine", + " restore_image: postgres:17.10-alpine", + ), + ( + " target_image: postgres:17.10-alpine", + " restore_image: postgres:18.4-alpine", + ), + ( + " target_image: postgres:18.4-alpine", + " restore_image: postgres:18.4-alpine", + ), + ) + for target, restore in additions: + self.assertIn(target, source) + source = source.replace(target, f"{target}\n{restore}", 1) + return source + + def test_current_repository_follows_contract(self) -> None: + self.assertEqual([], self.module.check_repository(ROOT)) + + def test_inventory_covers_every_maintained_surface(self) -> None: + self.assertEqual(EXPECTED_SURFACES, set(self.module.MAINTAINED_TEXT_PATHS)) + for relative in EXPECTED_SURFACES: + with self.subTest(relative=relative): + self.assertTrue((ROOT / relative).is_file()) + + failures = self.module.check_repository(Path("/does-not-exist")) + for relative in EXPECTED_SURFACES: + with self.subTest(missing=relative): + self.assertIn( + f"missing maintained image surface: {relative}", + failures, + ) + + def test_retired_markers_are_rejected_regardless_of_text_syntax(self) -> None: + cases = ( + "# historical book" + "worm image\n", + 'IMAGE="debian' + '12"\n', + "base: Debian 12\n", + "base: debian-12\n", + "base: debian_12\n", + "base: debian:12\n", + "base: Debian v12\n", + '{"base": "BOOK' + 'WORM"}\n', + ) + for suffix in cases: + with self.subTest(suffix=suffix): + root = self.fixture() + target = root / TUTORIAL_CHECK + target.write_text( + target.read_text(encoding="utf-8") + suffix, + encoding="utf-8", + ) + self.assert_has_failure( + root, + f"{TUTORIAL_CHECK}: retired Debian image generation marker remains", + ) + + def test_retired_marker_does_not_expand_to_slash_or_dot_separators( + self, + ) -> None: + for suffix in ( + "base: Debian/12\n", + "base: Debian.12\n", + "base: Debian/v12\n", + "base: Debian.v12\n", + ): + with self.subTest(suffix=suffix): + root = self.fixture() + target = root / TUTORIAL_CHECK + target.write_text( + target.read_text(encoding="utf-8") + suffix, + encoding="utf-8", + ) + self.assertEqual([], self.module.check_repository(root)) + + def test_workflow_image_allowlist_has_only_reviewed_postgres_images( + self, + ) -> None: + relay_image = "postgres:${{ matrix.postgresql }}-alpine" + source_images = ( + "postgres:16.13-alpine", + "postgres:17.9-alpine", + "postgres:18.3-alpine", + ) + target_images = ( + "postgres:16.14-alpine", + "postgres:17.10-alpine", + "postgres:18.4-alpine", + ) + restore_images = ( + "postgres:17.10-alpine", + "postgres:18.4-alpine", + ) + self.assertEqual( + {(RELAY_POSTGRES_WORKFLOW, "image", relay_image)} + | { + (NOTARY_POSTGRES_WORKFLOW, role, image) + for role, images in ( + ("source_image", source_images), + ("target_image", target_images), + ("restore_image", restore_images), + ) + for image in images + }, + set(self.module.WORKFLOW_IMAGE_ALLOWLIST), + ) + self.assertEqual( + {"image", "source_image", "target_image", "restore_image"}, + set(self.module.WORKFLOW_IMAGE_KEYS), + ) + self.assertEqual( + { + "default_source_image", + "default_target_image", + "default_restore_image", + }, + set(self.module.WORKFLOW_DEFAULT_IMAGE_KEYS), + ) + self.assertEqual( + source_images, + self.module.NOTARY_POSTGRES_WORKFLOW_SOURCE_IMAGES, + ) + self.assertEqual( + target_images, + self.module.NOTARY_POSTGRES_WORKFLOW_TARGET_IMAGES, + ) + self.assertEqual( + restore_images, + self.module.NOTARY_POSTGRES_WORKFLOW_RESTORE_IMAGES, + ) + for rationale in self.module.WORKFLOW_IMAGE_ALLOWLIST.values(): + self.assertIn("not a project-owned Debian image", rationale) + + workflow = ( + "name: External PostgreSQL conformance\n" + "jobs:\n" + " state-plane:\n" + " services:\n" + " postgres:\n" + f' image: "{relay_image}"\n' + ) + root = self.fixture() + self.write_workflow(root, RELAY_POSTGRES_WORKFLOW.name, workflow) + notary_target = root / NOTARY_POSTGRES_WORKFLOW + shutil.copyfile(ROOT / NOTARY_POSTGRES_WORKFLOW, notary_target) + self.assertEqual([], self.module.check_repository(root)) + + root = self.fixture() + self.write_workflow(root, "copied-postgres.yml", workflow) + self.assert_has_failure( + root, + "copied-postgres.yml: workflow image reference is not allowlisted", + ) + + def test_dynamic_workflow_images_are_denied_from_structured_forms( + self, + ) -> None: + digest_image = "ghcr.io/example/tool@sha256:" + "a" * 64 + cases = ( + ( + "scalar-container.yaml", + "name: Scalar container\n" + "jobs:\n" + " build:\n" + " container: rust:1.95-trixie\n", + ), + ( + "flow-container.yml", + 'name: Flow container\njobs: {build: {container: "' + + digest_image + + '"}}\n', + ), + ( + "anchored-container.yml", + "name: Anchored container\n" + "x-builder: &builder rust:1.95-trixie\n" + "jobs:\n" + " build:\n" + " container: *builder\n", + ), + ( + "mapping-container.yml", + "name: Mapping container\n" + "jobs:\n" + " build:\n" + f' container: {{image: "{digest_image}"}}\n', + ), + ( + "service-image.yml", + "name: Service image\n" + "jobs:\n" + " build:\n" + " services:\n" + " database:\n" + f' image: "{digest_image}"\n', + ), + ( + "docker-uses.yml", + "name: Docker action\n" + "jobs:\n" + " build:\n" + " steps:\n" + " - uses: docker://alpine:3.22\n", + ), + ( + "matrix-source-image.yml", + "name: Migration source\n" + "jobs:\n" + " build:\n" + " strategy:\n" + " matrix:\n" + " include:\n" + " - source_image: postgres:16.13-alpine\n", + ), + ( + "flow-target-image.yml", + "name: Migration target\n" + "jobs: {build: {strategy: {matrix: {include: " + '[{target_image: "postgres:16.14-alpine"}]}}}}\n', + ), + ) + for name, workflow in cases: + with self.subTest(name=name): + root = self.fixture() + relative = Path(".github/workflows") / name + self.assertNotIn(relative, self.module.MAINTAINED_TEXT_PATHS) + self.write_workflow(root, name, workflow) + self.assert_has_failure( + root, + f"{relative}: workflow image reference is not allowlisted", + ) + + def test_notary_matrix_images_match_exact_roles( + self, + ) -> None: + source = self.notary_workflow_with_restore_images() + failures: list[str] = [] + references = self.module.collect_workflow_image_references( + source, + NOTARY_POSTGRES_WORKFLOW, + failures, + ) + self.assertEqual([], failures) + self.assertEqual( + { + ("source_image", "postgres:16.13-alpine"), + ("source_image", "postgres:17.9-alpine"), + ("source_image", "postgres:18.3-alpine"), + ("target_image", "postgres:16.14-alpine"), + ("target_image", "postgres:17.10-alpine"), + ("target_image", "postgres:18.4-alpine"), + ("restore_image", "postgres:17.10-alpine"), + ("restore_image", "postgres:18.4-alpine"), + }, + {(role, value) for role, _location, value in references}, + ) + + root = self.fixture() + self.write_workflow( + root, + NOTARY_POSTGRES_WORKFLOW.name, + source, + ) + self.assertEqual([], self.module.check_repository(root)) + + def test_notary_matrix_images_reject_cross_role_and_mutable_values( + self, + ) -> None: + source = self.notary_workflow_with_restore_images() + cases = ( + ( + "target-as-source", + "source_image: postgres:16.13-alpine", + "source_image: postgres:16.14-alpine", + "postgres:16.14-alpine", + ), + ( + "source-as-target", + "target_image: postgres:16.14-alpine", + "target_image: postgres:16.13-alpine", + "postgres:16.13-alpine", + ), + ( + "source-as-restore", + "restore_image: postgres:17.10-alpine", + "restore_image: postgres:16.13-alpine", + "postgres:16.13-alpine", + ), + ( + "target-only-as-restore", + "restore_image: postgres:17.10-alpine", + "restore_image: postgres:16.14-alpine", + "postgres:16.14-alpine", + ), + ( + "mutable-source", + "source_image: postgres:16.13-alpine", + "source_image: postgres:16-alpine", + "postgres:16-alpine", + ), + ( + "mutable-target", + "target_image: postgres:16.14-alpine", + "target_image: postgres:16-alpine", + "postgres:16-alpine", + ), + ( + "mutable-restore", + "restore_image: postgres:17.10-alpine", + "restore_image: postgres:17", + "postgres:17", + ), + ) + failure = ( + f"{NOTARY_POSTGRES_WORKFLOW}: workflow image reference " + "is not allowlisted" + ) + for name, exact, replacement, leaked_value in cases: + with self.subTest(name=name): + self.assertIn(exact, source) + root = self.fixture() + self.write_workflow( + root, + NOTARY_POSTGRES_WORKFLOW.name, + source.replace(exact, replacement, 1), + ) + failures = self.module.check_repository(root) + self.assertTrue( + any(failure in item for item in failures), + failures, + ) + self.assertNotIn(leaked_value, "\n".join(failures)) + + def test_relay_image_exception_does_not_authorize_other_roles(self) -> None: + source = (ROOT / RELAY_POSTGRES_WORKFLOW).read_text(encoding="utf-8") + relay_image = "postgres:${{ matrix.postgresql }}-alpine" + exact = f" image: {relay_image}" + replacements = ( + f" container: {relay_image}", + " container:\n" + f" image: {relay_image}", + f" uses: docker://{relay_image}", + f" default_source_image: {relay_image}", + ) + self.assertIn(exact, source) + for replacement in replacements: + with self.subTest(replacement=replacement): + root = self.fixture() + self.write_workflow( + root, + RELAY_POSTGRES_WORKFLOW.name, + source.replace(exact, replacement, 1), + ) + failures = self.module.check_repository(root) + self.assertTrue( + any( + f"{RELAY_POSTGRES_WORKFLOW}: workflow image reference " + "is not allowlisted" in item + for item in failures + ), + failures, + ) + self.assertNotIn(relay_image, "\n".join(failures)) + + def test_workflow_image_inventory_fails_closed(self) -> None: + cases = ( + ( + "malformed.yml", + "jobs: [\n", + "workflow YAML parse failed", + ), + ( + "root-list.yml", + "- jobs\n", + "workflow YAML root must be a mapping", + ), + ( + "container-list.yml", + "jobs:\n" + " build:\n" + " container:\n" + " - rust:1.95-trixie\n", + "unsupported workflow image value", + ), + ( + "container-without-image.yml", + "jobs:\n" + " build:\n" + " container:\n" + " options: --read-only\n", + "unsupported workflow image value", + ), + ( + "mapping-image.yml", + "jobs:\n" + " build:\n" + " services:\n" + " database:\n" + " image:\n" + " name: rust:1.95-trixie\n", + "unsupported workflow image value", + ), + ( + "empty-docker-uses.yml", + "jobs:\n" + " build:\n" + " steps:\n" + " - uses: docker://\n", + "unsupported workflow image value", + ), + ( + "invalid-source-image.yml", + "jobs:\n" + " build:\n" + " strategy:\n" + " matrix:\n" + " include:\n" + " - source_image: []\n", + "unsupported workflow image value", + ), + ) + for name, workflow, failure in cases: + with self.subTest(name=name): + root = self.fixture() + self.write_workflow(root, name, workflow) + self.assert_has_failure(root, failure) + + def test_notary_postgres_script_has_exact_reviewed_image_defaults( + self, + ) -> None: + self.assertEqual( + ( + ("default_source_image", '"postgres:16.13-alpine"'), + ("default_target_image", '"postgres:16.14-alpine"'), + ("default_restore_image", '"postgres:17.10-alpine"'), + ("default_source_image", '"postgres:17.9-alpine"'), + ("default_target_image", '"postgres:17.10-alpine"'), + ("default_restore_image", '"postgres:18.4-alpine"'), + ("default_source_image", '"postgres:18.3-alpine"'), + ("default_target_image", '"postgres:18.4-alpine"'), + ("default_restore_image", '"postgres:18.4-alpine"'), + ("unsupported_postgres_image", '"postgres:15.18-alpine"'), + ), + self.module.NOTARY_POSTGRES_IMAGE_ASSIGNMENTS, + ) + self.assertEqual( + ( + ( + "source_image", + '"${NOTARY_POSTGRES_SOURCE_IMAGE:-' + '${default_source_image}}"', + ), + ( + "target_image", + '"${NOTARY_POSTGRES_TARGET_IMAGE:-' + '${default_target_image}}"', + ), + ( + "restore_image", + '"${NOTARY_POSTGRES_RESTORE_IMAGE:-' + '${default_restore_image}}"', + ), + ), + self.module.NOTARY_POSTGRES_FALLBACK_BINDINGS, + ) + + def test_notary_postgres_image_assignments_reject_mutable_defaults( + self, + ) -> None: + cases = ( + ( + ' default_source_image="postgres:16.13-alpine"', + ' default_source_image="postgres:16"', + ), + ( + ' default_restore_image="postgres:17.10-alpine"', + ' default_restore_image="postgres:17"', + ), + ( + 'unsupported_postgres_image="postgres:15.18-alpine"', + 'unsupported_postgres_image="postgres:15"', + ), + ) + failure = ( + "PostgreSQL image assignments must match the exact ordered " + "reviewed inventory" + ) + for exact, replacement in cases: + with self.subTest(replacement=replacement): + root = self.fixture() + target = root / NOTARY_POSTGRES_CONFORMANCE_SCRIPT + text = target.read_text(encoding="utf-8") + self.assertIn(exact, text) + target.write_text( + text.replace(exact, replacement, 1), + encoding="utf-8", + ) + failures = self.module.check_repository(root) + self.assertTrue( + any(failure in item for item in failures), + failures, + ) + self.assertNotIn(replacement, "\n".join(failures)) + + def test_notary_postgres_defaults_cannot_be_shadowed_by_comments( + self, + ) -> None: + exact = ' default_restore_image="postgres:17.10-alpine"' + replacement = ( + f" # {exact.strip()}\n" + ' default_restore_image="postgres:17"' + ) + root = self.fixture() + target = root / NOTARY_POSTGRES_CONFORMANCE_SCRIPT + text = target.read_text(encoding="utf-8") + self.assertIn(exact, text) + target.write_text( + text.replace(exact, replacement, 1), + encoding="utf-8", + ) + self.assert_has_failure( + root, + "PostgreSQL image assignments must match the exact ordered " + "reviewed inventory", + ) + + def test_notary_postgres_rejects_new_direct_image_literals( + self, + ) -> None: + failure = ( + "direct PostgreSQL image literals must match the exact ordered " + "reviewed assignment lines" + ) + for addition in ( + 'direct_image="postgres:16"\n', + "docker pull postgres:16\n", + ): + with self.subTest(addition=addition): + root = self.fixture() + target = root / NOTARY_POSTGRES_CONFORMANCE_SCRIPT + text = target.read_text(encoding="utf-8") + target.write_text(text + addition, encoding="utf-8") + failures = self.module.check_repository(root) + self.assertTrue( + any(failure in item for item in failures), + failures, + ) + self.assertNotIn(addition.strip(), "\n".join(failures)) + + root = self.fixture() + target = root / NOTARY_POSTGRES_CONFORMANCE_SCRIPT + text = target.read_text(encoding="utf-8") + target.write_text( + text + '# example_image="postgres:16"\n', + encoding="utf-8", + ) + self.assertEqual([], self.module.check_repository(root)) + + def test_notary_postgres_env_fallbacks_remain_bound_to_defaults( + self, + ) -> None: + exact = ( + 'source_image="${NOTARY_POSTGRES_SOURCE_IMAGE:-' + '${default_source_image}}"' + ) + replacements = ( + ( + 'source_image="${NOTARY_POSTGRES_SOURCE_IMAGE:-' + 'postgres:16}"' + ), + f"# {exact}\nsource_image=\"postgres:16\"", + f"{exact}\nsource_image=\"postgres:16\"", + ) + failure = ( + "PostgreSQL environment fallbacks must match the exact ordered " + "reviewed bindings" + ) + for replacement in replacements: + with self.subTest(replacement=replacement): + root = self.fixture() + target = root / NOTARY_POSTGRES_CONFORMANCE_SCRIPT + text = target.read_text(encoding="utf-8") + self.assertIn(exact, text) + target.write_text( + text.replace(exact, replacement, 1), + encoding="utf-8", + ) + failures = self.module.check_repository(root) + self.assertTrue( + any(failure in item for item in failures), + failures, + ) + self.assertNotIn(replacement, "\n".join(failures)) + + def test_retired_markers_cover_dynamically_discovered_workflows( + self, + ) -> None: + root = self.fixture() + relative = Path(".github/workflows/dynamic-policy.yaml") + self.write_workflow( + root, + relative.name, + "name: Debian v12 compatibility\njobs: {}\n", + ) + self.assert_has_failure( + root, + f"{relative}: retired Debian image generation marker remains", + ) + + def test_tutorial_builder_must_match_the_exact_pinned_image(self) -> None: + exact = f'BUILDER_IMAGE="{self.module.RUST_BUILDER}"' + cases = ( + "", + 'BUILDER_IMAGE="rust:1.94-trixie@sha256:' + "a" * 64 + '"', + 'BUILDER_IMAGE="rust:1.95-trixie"', + ) + for replacement in cases: + with self.subTest(replacement=replacement): + root = self.fixture() + target = root / TUTORIAL_CHECK + text = target.read_text(encoding="utf-8") + self.assertIn(exact, text) + target.write_text( + text.replace(exact, replacement, 1), + encoding="utf-8", + ) + self.assert_has_failure( + root, + "missing pinned Debian 13 registryctl tutorial builder", + ) + + def test_builder_contract_lines_cannot_be_shadowed_by_comments(self) -> None: + pin = self.module.RUST_BUILDER + cases = ( + ( + RELEASE_WORKFLOW, + f" RELEASE_BUILDER_IMAGE: {pin}", + f" # RELEASE_BUILDER_IMAGE: {pin}\n" + " RELEASE_BUILDER_IMAGE: rust:1.95-trixie", + "missing pinned Debian 13 release builder", + ), + ( + RELEASE_BINARY_RECIPE, + f'default_builder_image="{pin}"', + f'# default_builder_image="{pin}"\n' + 'default_builder_image="rust:1.95-trixie"', + "missing pinned Debian 13 release recipe builder", + ), + ( + TUTORIAL_CHECK, + f'BUILDER_IMAGE="{pin}"', + f'# BUILDER_IMAGE="{pin}"\nBUILDER_IMAGE="rust:1.95-trixie"', + "missing pinned Debian 13 registryctl tutorial builder", + ), + ( + LIVE_JOURNEY, + f" {pin} \\", + f" # {pin} \\\n rust:1.95-trixie \\", + "missing pinned Debian 13 live-journey builder", + ), + ) + for relative, exact, replacement, failure in cases: + with self.subTest(relative=relative): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + self.assertIn(exact, text) + target.write_text( + text.replace(exact, replacement, 1), + encoding="utf-8", + ) + self.assert_has_failure(root, failure) + + def test_builder_contracts_reject_earlier_and_later_active_overrides( + self, + ) -> None: + pin = self.module.RUST_BUILDER + cases = ( + ( + RELEASE_WORKFLOW, + f" RELEASE_BUILDER_IMAGE: {pin}", + " RELEASE_BUILDER_IMAGE: rust:1.95-trixie", + "missing pinned Debian 13 release builder", + ), + ( + RELEASE_BINARY_RECIPE, + f'default_builder_image="{pin}"', + 'default_builder_image="rust:1.95-trixie"', + "missing pinned Debian 13 release recipe builder", + ), + ( + TUTORIAL_CHECK, + f'BUILDER_IMAGE="{pin}"', + 'BUILDER_IMAGE="rust:1.95-trixie"', + "missing pinned Debian 13 registryctl tutorial builder", + ), + ) + for relative, exact, override, failure in cases: + for replacement in (f"{override}\n{exact}", f"{exact}\n{override}"): + with self.subTest(relative=relative, replacement=replacement): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text( + text.replace(exact, replacement, 1), + encoding="utf-8", + ) + self.assert_has_failure(root, failure) + + root = self.fixture() + target = root / LIVE_JOURNEY + exact = f" {pin} \\" + text = target.read_text(encoding="utf-8") + target.write_text( + text.replace(exact, exact + "\n rust:1.95-trixie \\", 1), + encoding="utf-8", + ) + self.assert_has_failure( + root, + "missing pinned Debian 13 live-journey builder", + ) + + def test_shell_builder_contracts_allow_only_one_canonical_assignment( + self, + ) -> None: + pin = self.module.RUST_BUILDER + cases = ( + ( + RELEASE_BINARY_RECIPE, + f'default_builder_image="{pin}"', + "default_builder_image", + "missing pinned Debian 13 release recipe builder", + ), + ( + TUTORIAL_CHECK, + f'BUILDER_IMAGE="{pin}"', + "BUILDER_IMAGE", + "missing pinned Debian 13 registryctl tutorial builder", + ), + ( + RELEASE_BINARY_RECIPE, + self.module.RELEASE_BUILDER_HANDOFF, + "release_builder_image", + "missing release builder handoff", + ), + ) + for relative, exact, variable, failure in cases: + for prefix in ("readonly ", "export "): + with self.subTest( + relative=relative, + prefix=prefix, + valid=True, + ): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text( + text.replace(exact, prefix + exact, 1), + encoding="utf-8", + ) + self.assertEqual([], self.module.check_repository(root)) + + invalid_replacements = ( + f"readonly {exact}\nexport {exact}", + f'readonly {variable}="rust:1.95-trixie"', + f'export {variable}="rust:1.95-trixie"', + ) + for replacement in invalid_replacements: + with self.subTest( + relative=relative, + replacement=replacement, + valid=False, + ): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text( + text.replace(exact, replacement, 1), + encoding="utf-8", + ) + self.assert_has_failure(root, failure) + + def test_live_journey_uses_the_reviewed_postgres_alpine_image( + self, + ) -> None: + exact = self.module.LIVE_JOURNEY_POSTGRES_ASSIGNMENT + self.assertEqual( + 'readonly POSTGRES_IMAGE="postgres:16.13-alpine"', + exact, + ) + cases = ( + 'readonly POSTGRES_IMAGE="postgres:16"', + 'readonly POSTGRES_IMAGE="postgres:16-alpine"', + 'readonly POSTGRES_IMAGE="postgres:17.9-alpine"', + f"{exact}\n" + 'POSTGRES_IMAGE="postgres:16"', + f"# {exact}\n" + 'POSTGRES_IMAGE="postgres:16"', + ) + failure = ( + "live-journey PostgreSQL image assignment must remain the " + "single reviewed value" + ) + for replacement in cases: + with self.subTest(replacement=replacement): + root = self.fixture() + target = root / LIVE_JOURNEY + text = target.read_text(encoding="utf-8") + self.assertIn(exact, text) + target.write_text( + text.replace(exact, replacement, 1), + encoding="utf-8", + ) + failures = self.module.check_repository(root) + self.assertTrue( + any(failure in item for item in failures), + failures, + ) + for line in replacement.splitlines(): + if line != exact: + self.assertNotIn(line, "\n".join(failures)) + + def test_live_journey_postgres_consumers_are_bound_to_both_commands( + self, + ) -> None: + consumer = self.module.LIVE_JOURNEY_POSTGRES_CONSUMER + replacement = ' "postgres:17.9-alpine" \\' + failure_fragments = ( + "live-journey PostgreSQL certificate setup command", + "live-journey PostgreSQL server command", + ) + source = (ROOT / LIVE_JOURNEY).read_text(encoding="utf-8") + self.assertEqual(2, source.splitlines().count(consumer)) + indexes = [ + index for index, line in enumerate(source.splitlines()) if line == consumer + ] + for occurrence, failure in zip(indexes, failure_fragments): + with self.subTest(occurrence=occurrence): + root = self.fixture() + target = root / LIVE_JOURNEY + lines = source.splitlines() + lines[occurrence] = replacement + lines.append(consumer) + target.write_text("\n".join(lines) + "\n", encoding="utf-8") + failures = self.module.check_repository(root) + self.assertTrue( + any(failure in item for item in failures), + failures, + ) + self.assertNotIn(replacement, "\n".join(failures)) + + def test_builder_handoffs_and_docker_consumers_remain_exact(self) -> None: + cases = ( + ( + RELEASE_BINARY_RECIPE, + self.module.RELEASE_BUILDER_HANDOFF, + 'release_builder_image="rust:1.95-trixie"', + "missing release builder handoff", + ), + ( + RELEASE_BINARY_RECIPE, + self.module.RELEASE_BUILDER_CONSUMER, + ' "rust:1.95-trixie" \\', + "missing release Docker builder command tail", + ), + ( + TUTORIAL_CHECK, + self.module.TUTORIAL_BUILDER_CONSUMER, + '\t\t"rust:1.95-trixie" \\', + "missing registryctl tutorial Docker builder command tail", + ), + ) + for relative, exact, unpinned, failure in cases: + for replacement in (unpinned, f"{exact}\n{exact}"): + with self.subTest( + relative=relative, + replacement=replacement, + ): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + self.assertIn(exact, text) + target.write_text( + text.replace(exact, replacement, 1), + encoding="utf-8", + ) + self.assert_has_failure(root, failure) + + def test_docker_builder_tails_reject_preceding_positional_images( + self, + ) -> None: + cases = ( + ( + RELEASE_BINARY_RECIPE, + self.module.RELEASE_BUILDER_PREFIX, + self.module.RELEASE_BUILDER_CONSUMER, + " ", + "does not match the exact expected header/options/image prefix", + ), + ( + TUTORIAL_CHECK, + self.module.TUTORIAL_BUILDER_PREFIX, + self.module.TUTORIAL_BUILDER_CONSUMER, + "\t\t", + "does not match the exact expected header/options/image prefix", + ), + ( + LIVE_JOURNEY, + self.module.LIVE_JOURNEY_BUILDER_PREFIX, + self.module.LIVE_JOURNEY_BUILDER, + " ", + "does not match the exact expected header/options/image prefix", + ), + ) + for ( + relative, + expected_prefix, + approved, + indentation, + failure, + ) in cases: + command_start = expected_prefix[0] + last_option = expected_prefix[-2] + for image in ("alpine:3.22", "debian:trixie-slim"): + positional = f"{indentation}{image} \\" + positions = ( + ( + "before-first-option", + command_start, + command_start + "\n" + positional, + ), + ( + "before-last-option", + last_option, + positional + "\n" + last_option, + ), + ( + "before-approved-image", + approved, + positional + "\n" + approved, + ), + ) + for position, anchor, replacement in positions: + with self.subTest( + relative=relative, + image=image, + position=position, + ): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + self.assertIn(anchor, text) + target.write_text( + text.replace(anchor, replacement, 1), + encoding="utf-8", + ) + self.assert_has_failure(root, failure) + + def test_docker_builder_prefixes_reject_images_on_option_lines( + self, + ) -> None: + cases = ( + ( + RELEASE_BINARY_RECIPE, + self.module.RELEASE_BUILDER_PREFIX, + ), + ( + TUTORIAL_CHECK, + self.module.TUTORIAL_BUILDER_PREFIX, + ), + ( + LIVE_JOURNEY, + self.module.LIVE_JOURNEY_BUILDER_PREFIX, + ), + ) + failure = "does not match the exact expected header/options/image prefix" + for relative, expected_prefix in cases: + for image in ("alpine:3.22", "debian:trixie-slim"): + for position, option in ( + ("early", expected_prefix[1]), + ("late", expected_prefix[-2]), + ): + with self.subTest( + relative=relative, + image=image, + position=position, + ): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + self.assertIn(option, text) + mutated = option[:-1] + f"{image} \\" + target.write_text( + text.replace(option, mutated, 1), + encoding="utf-8", + ) + self.assert_has_failure(root, failure) + + def test_every_dockerfile_base_requires_an_immutable_digest(self) -> None: + for relative in self.module.DOCKERFILES: + with self.subTest(relative=relative): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + base = next(self.module.FROM_RE.finditer(text)).group("base") + self.assertIn("@sha256:", base) + target.write_text( + text.replace(base, base.split("@sha256:", 1)[0], 1), + encoding="utf-8", + ) + self.assert_has_failure( + root, + f"{relative}: upstream base is not pinned by immutable digest", + ) + + def test_dockerfile_copy_sources_are_reviewed_stages_or_contexts( + self, + ) -> None: + self.assertEqual( + {"from", "chown"}, + set(self.module.COPY_OPTION_NAMES), + ) + self.assertEqual( + set(self.module.DOCKERFILES), + set(self.module.DOCKERFILE_NAMED_CONTEXTS), + ) + for relative in self.module.DOCKERFILES: + with self.subTest(relative=relative): + text = (ROOT / relative).read_text(encoding="utf-8") + aliases = { + match.group("alias").casefold() + for match in self.module.FROM_RE.finditer(text) + if match.group("alias") is not None + } + failures: list[str] = [] + instructions = self.module.normalize_dockerfile_instructions( + text, + relative, + failures, + ) + sources = set( + self.module.collect_dockerfile_copy_sources( + instructions, + relative, + failures, + ) + ) + self.assertEqual([], failures) + external_sources = { + source for source in sources if source.casefold() not in aliases + } + self.assertEqual( + self.module.DOCKERFILE_NAMED_CONTEXTS[relative], + external_sources, + ) + + def test_dockerfile_copy_sources_reject_external_images_and_contexts( + self, + ) -> None: + sources = ( + "unreviewed-context", + "alpine:3.22", + "ghcr.io/example/tool@sha256:" + "a" * 64, + ) + failure = ( + "COPY --from source is not a declared stage or reviewed named " + "build context" + ) + for relative in self.module.DOCKERFILES: + for source in sources: + with self.subTest(relative=relative, source=source): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text( + text + f"\n copy --chown=65532:65532 --from={source} " + "/bin/tool /bin/tool\n", + encoding="utf-8", + ) + failures = self.module.check_repository(root) + self.assertTrue( + any(failure in item for item in failures), + failures, + ) + self.assertNotIn(source, "\n".join(failures)) + + def test_dockerfile_copy_options_require_canonical_reviewed_syntax( + self, + ) -> None: + relative = Path("crates/registry-relay/Dockerfile") + cases = ( + ("escaped-from-name", r"--fr\om=alpine:3.22"), + ("quoted-from-name", '--fr"om"=alpine:3.22'), + ("unknown-chmod", "--chmod=0755"), + ("unknown-link", "--link=true"), + ("valueless-from", "--from"), + ("quoted-from-value", '--from="builder"'), + ("escaped-from-value", r"--from=buil\der"), + ("uppercase-from", "--FROM=builder"), + ( + "duplicate-from", + "--from=builder --from=registry-platform", + ), + ) + failure = "unsupported COPY option syntax" + for name, options in cases: + with self.subTest(name=name): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text( + text + f"\nCOPY {options} /bin/tool /bin/tool\n", + encoding="utf-8", + ) + failures = self.module.check_repository(root) + self.assertTrue( + any(failure in item for item in failures), + failures, + ) + self.assertNotIn(options, "\n".join(failures)) + + def test_dockerfile_copy_rejects_encoded_option_like_operands( + self, + ) -> None: + relative = Path("crates/registry-relay/Dockerfile") + for prefix in ( + r'-\-from=alpine:3.22', + r'\--from=alpine:3.22', + '"--from"=alpine:3.22', + ): + with self.subTest(prefix=prefix): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text( + text + f"\nCOPY {prefix} /bin/tool /bin/tool\n", + encoding="utf-8", + ) + failures = self.module.check_repository(root) + self.assertTrue( + any( + "unsupported COPY operand prefix" in item + for item in failures + ), + failures, + ) + self.assertNotIn(prefix, "\n".join(failures)) + + def test_multiline_dockerfile_copy_sources_allow_reviewed_sources( + self, + ) -> None: + cases = ( + ( + Path("crates/registry-relay/Dockerfile"), + ( + "COPY --from=registry-platform /Cargo.toml /Cargo.lock " + "/workspace/registry-platform/" + ), + "COPY --chown=0:0 \\\n" + " # Supplied as a named BuildKit context.\n" + " --from=registry-platform \\\n" + " /Cargo.toml /Cargo.lock /workspace/registry-platform/", + ), + ( + Path("release/docker/Dockerfile.registry-relay"), + "COPY --from=runtime-root /workspace/runtime-root/ /", + "COPY \\\n" + " --from=runtime-root \\\n" + " /workspace/runtime-root/ /", + ), + ) + for relative, exact, multiline in cases: + with self.subTest(relative=relative): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + self.assertIn(exact, text) + target.write_text( + text.replace(exact, multiline, 1), + encoding="utf-8", + ) + self.assertEqual([], self.module.check_repository(root)) + + def test_multiline_dockerfile_copy_sources_reject_external_images( + self, + ) -> None: + relative = Path("crates/registry-relay/Dockerfile") + sources = ( + "alpine:3.22", + "ghcr.io/example/tool@sha256:" + "a" * 64, + ) + instructions = ( + "COPY \\\n" + " --from={source} \\\n" + " /bin/tool /bin/tool\n", + "COPY --chown=65532:65532 \\\n" + " --from={source} \\\n" + " /bin/tool /bin/tool\n", + "COPY --from={source} \\\n" + " --chown=65532:65532 \\\n" + " /bin/tool /bin/tool\n", + "COPY --chown=65532:65532 \\\n" + " # The source option remains part of this instruction.\n" + " --from={source} \\\n" + " /bin/tool /bin/tool\n", + ) + failure = ( + "COPY --from source is not a declared stage or reviewed named " + "build context" + ) + for source in sources: + for instruction in instructions: + with self.subTest(source=source, instruction=instruction): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text( + text + "\n" + instruction.format(source=source), + encoding="utf-8", + ) + failures = self.module.check_repository(root) + self.assertTrue( + any(failure in item for item in failures), + failures, + ) + self.assertNotIn(source, "\n".join(failures)) + + def test_dockerfile_copy_normalization_fails_closed(self) -> None: + relative = Path("crates/registry-relay/Dockerfile") + cases = ( + ( + "unterminated", + "", + "\nCOPY --chown=65532:65532 \\\n" + " # No continued instruction follows.\n", + "unterminated Dockerfile line continuation", + ), + ( + "alternate-escape", + "# escape=`\n", + "\nCOPY --chown=65532:65532 `\n" + " --from=alpine:3.22 `\n" + " /bin/tool /bin/tool\n", + "unsupported Dockerfile escape directive", + ), + ) + for name, prefix, suffix, failure in cases: + with self.subTest(name=name): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text(prefix + text + suffix, encoding="utf-8") + self.assert_has_failure(root, failure) + + def test_multiline_copy_source_tokens_cannot_shadow_reviewed_names( + self, + ) -> None: + relative = Path("crates/registry-relay/Dockerfile") + for reviewed in ("builder", "registry-platform"): + with self.subTest(reviewed=reviewed): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text( + text + + f"\nCOPY --from={reviewed}\\\n" + "-external \\\n" + " /bin/tool /bin/tool\n", + encoding="utf-8", + ) + self.assert_has_failure( + root, + f"{relative}: COPY --from source is not a declared stage " + "or reviewed named build context", + ) + + def test_dockerfile_run_inventories_match_current_instructions( + self, + ) -> None: + self.assertEqual( + set(self.module.DOCKERFILES), + set(self.module.DOCKERFILE_RUN_INSTRUCTIONS), + ) + for relative in self.module.DOCKERFILES: + with self.subTest(relative=relative): + failures: list[str] = [] + instructions = self.module.normalize_dockerfile_instructions( + (ROOT / relative).read_text(encoding="utf-8"), + relative, + failures, + ) + actual = tuple( + " ".join(instruction.split()) + for instruction in instructions + if self.module.RUN_INSTRUCTION_RE.match(instruction) + ) + self.assertEqual([], failures) + self.assertEqual( + self.module.DOCKERFILE_RUN_INSTRUCTIONS[relative], + actual, + ) + + def test_dockerfile_run_inventories_reject_any_command_change( + self, + ) -> None: + release_relay = Path("release/docker/Dockerfile.registry-relay") + product_relay = Path("crates/registry-relay/Dockerfile") + runtime_marker = f"FROM {self.module.DISTROLESS_RUNTIME} AS runtime" + cases = ( + ( + "runtime-root-shell", + product_relay, + " chown -R 65532:65532 /workspace/runtime-root", + " mkdir -p /workspace/runtime-root/bin && \\\n" + " cp /bin/bash /workspace/runtime-root/bin/bash && \\\n" + " chown -R 65532:65532 /workspace/runtime-root", + ), + ( + "bind-source-drift", + release_relay, + "source=dist/image-bin", + "source=dist/other-bin", + ), + ( + "bind-target-drift", + release_relay, + "target=/workspace/image-bin", + "target=/workspace/other-bin", + ), + ( + "external-mount-from", + release_relay, + "type=bind,source=dist/image-bin", + "type=bind,from=debian:trixie-slim," + "source=dist/image-bin", + ), + ( + "quoted-mount", + release_relay, + "--mount=type=bind,source=dist/image-bin," + "target=/workspace/image-bin", + '--mount="type=bind,source=dist/image-bin,' + 'target=/workspace/image-bin"', + ), + ( + "extra-run", + product_relay, + runtime_marker, + f"RUN true\n\n{runtime_marker}", + ), + ) + failure = "RUN instructions must match the exact reviewed inventory" + for name, relative, exact, replacement in cases: + with self.subTest(name=name): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + self.assertIn(exact, text) + target.write_text( + text.replace(exact, replacement, 1), + encoding="utf-8", + ) + failures = self.module.check_repository(root) + self.assertTrue( + any(failure in item for item in failures), + failures, + ) + self.assertNotIn(replacement, "\n".join(failures)) + + def test_final_stage_copy_inventories_reject_extra_files(self) -> None: + self.assertEqual( + set(self.module.DOCKERFILES), + set(self.module.FINAL_STAGE_COPY_INSTRUCTIONS), + ) + for relative in self.module.DOCKERFILES: + with self.subTest(relative=relative): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + addition = "COPY /bin/bash /bin/bash" + target.write_text( + text + f"\n{addition}\n", + encoding="utf-8", + ) + failures = self.module.check_repository(root) + self.assertTrue( + any( + "final-stage COPY instructions must match the exact " + "reviewed inventory" in item + for item in failures + ), + failures, + ) + self.assertNotIn(addition, "\n".join(failures)) + + def test_dockerfile_named_context_allowlist_is_path_bounded(self) -> None: + for relative, contexts in self.module.DOCKERFILE_NAMED_CONTEXTS.items(): + for context in contexts: + with self.subTest(relative=relative, context=context): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + exact = f"--from={context}" + self.assertIn(exact, text) + target.write_text( + text.replace( + exact, + f"--from={context}-copy", + 1, + ), + encoding="utf-8", + ) + self.assert_has_failure( + root, + f"{relative}: COPY --from source is not a declared " + "stage or reviewed named build context", + ) + + def test_dockerfile_stages_reject_forced_platforms(self) -> None: + for relative in self.module.DOCKERFILES: + for stage_index in (0, 1): + with self.subTest( + relative=relative, + stage_index=stage_index, + ): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + stage = list(self.module.FROM_RE.finditer(text))[stage_index] + original = stage.group(0) + forced = original.replace( + "FROM ", + "FROM --platform=linux/amd64 ", + 1, + ) + target.write_text( + text[: stage.start()] + forced + text[stage.end() :], + encoding="utf-8", + ) + self.assert_has_failure( + root, + f"{relative}: Dockerfile stage sequence must be exactly", + ) + + def test_distroless_runtime_is_the_final_dockerfile_stage(self) -> None: + pinned_alpine = "alpine:3.22@sha256:" + "a" * 64 + for relative in self.module.DOCKERFILES: + additions = ( + f"\n from {pinned_alpine} as debug\n", + f"\n# FROM {self.module.DISTROLESS_RUNTIME} AS runtime\n" + f" from {self.module.DEBIAN_PREPARATION} as debug\n", + ) + for addition in additions: + with self.subTest(relative=relative, addition=addition): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text(text + addition, encoding="utf-8") + self.assert_has_failure( + root, + f"{relative}: Dockerfile stage sequence must be exactly", + ) + + def test_dockerfile_stage_sequence_rejects_aliases_duplicates_and_empty_runtime( + self, + ) -> None: + runtime = f"FROM {self.module.DISTROLESS_RUNTIME} AS runtime" + for relative in self.module.DOCKERFILES: + with self.subTest(relative=relative, mutation="alias"): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text( + text.replace(runtime, runtime.replace("runtime", "final"), 1), + encoding="utf-8", + ) + self.assert_has_failure( + root, + f"{relative}: Dockerfile stage sequence must be exactly", + ) + + with self.subTest(relative=relative, mutation="duplicate"): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text(text + f"\n{runtime}\n", encoding="utf-8") + self.assert_has_failure( + root, + f"{relative}: Dockerfile stage sequence must be exactly", + ) + + with self.subTest(relative=relative, mutation="empty"): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + final_stage = list(self.module.FROM_RE.finditer(text))[-1] + target.write_text( + text[: final_stage.end()] + "\n", + encoding="utf-8", + ) + self.assert_has_failure(root, f"{relative}: missing binary healthcheck") + + def test_runtime_directives_must_be_active_in_the_final_stage(self) -> None: + for relative in self.module.DOCKERFILES: + for directive in self.runtime_directives(relative): + with self.subTest(relative=relative, directive=directive): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + final_stage = list(self.module.FROM_RE.finditer(text))[-1] + prefix = text[: final_stage.start()] + runtime = text[final_stage.start() :] + self.assertIn(f"\n{directive}\n", runtime) + runtime = runtime.replace( + f"\n{directive}\n", + f"\n# {directive}\n", + 1, + ) + target.write_text( + prefix + directive + "\n" + runtime, + encoding="utf-8", + ) + self.assert_has_failure(root, f"{relative}: missing") + + def test_runtime_directive_overrides_and_users_are_rejected(self) -> None: + overrides = ( + (" healthcheck NONE", "exactly one active HEALTHCHECK"), + (' entrypoint ["/tmp/override"]', "exactly one active ENTRYPOINT"), + (" workdir /tmp", "exactly one active WORKDIR"), + ( + ' cmd ["--config", "/tmp/override.yaml"]', + "exactly one active CMD", + ), + ("USER 0", "must inherit the nonroot base user"), + ( + " volume /var/lib/registry", + "declare no writable VOLUME mount surfaces", + ), + ) + comments = "\n".join( + ( + "# HEALTHCHECK NONE", + '# ENTRYPOINT ["/tmp/override"]', + "# WORKDIR /tmp", + '# CMD ["--config", "/tmp/override.yaml"]', + "# USER 0", + "# VOLUME /var/lib/registry", + ) + ) + for relative in self.module.DOCKERFILES: + for override, failure in overrides: + with self.subTest(relative=relative, override=override): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text( + text + f"\n{override}\n", + encoding="utf-8", + ) + self.assert_has_failure(root, failure) + + with self.subTest(relative=relative, replaced_cmd=True): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + canonical_cmd = self.runtime_directives(relative)[-1] + target.write_text( + text.replace( + canonical_cmd, + 'CMD ["/tmp/override"]', + 1, + ), + encoding="utf-8", + ) + self.assert_has_failure(root, "exactly one active CMD") + + with self.subTest(relative=relative, comments=True): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + target.write_text( + text + f"\n{comments}\n", + encoding="utf-8", + ) + self.assertEqual([], self.module.check_repository(root)) + + def test_tutorial_cache_is_bound_to_the_builder_script_without_fallback( + self, + ) -> None: + exact = self.module.TUTORIAL_CACHE_KEY + for allowed in self.module.TUTORIAL_CACHE_KEYS[1:]: + with self.subTest(allowed=allowed): + root = self.fixture() + target = root / CI_WORKFLOW + text = target.read_text(encoding="utf-8") + target.write_text( + text.replace(exact, allowed, 1), + encoding="utf-8", + ) + self.assertEqual([], self.module.check_repository(root)) + + wrong_value = ( + "registryctl-tutorial-${{ runner.os }}-" + "${{ hashFiles('Cargo.lock') }}" + ) + cases = ( + ("", "missing registryctl tutorial builder cache key"), + ( + f" # {exact.strip()}", + "missing registryctl tutorial builder cache key", + ), + ( + f" key: {wrong_value}", + "missing registryctl tutorial builder cache key", + ), + ( + f" 'key': {wrong_value}", + "missing registryctl tutorial builder cache key", + ), + ( + f' "key": {wrong_value}', + "missing registryctl tutorial builder cache key", + ), + ( + exact + "\n" + self.module.TUTORIAL_CACHE_KEYS[1], + "missing registryctl tutorial builder cache key", + ), + ( + exact + + "\n restore-keys: |\n" + " registryctl-tutorial-${{ runner.os }}-", + "must not use restore-keys fallback", + ), + ( + exact + + "\n 'restore-keys': |\n" + " registryctl-tutorial-${{ runner.os }}-", + "must not use restore-keys fallback", + ), + ( + exact + + '\n "restore-keys": |\n' + " registryctl-tutorial-${{ runner.os }}-", + "must not use restore-keys fallback", + ), + ) + for replacement, failure in cases: + with self.subTest(replacement=replacement): + root = self.fixture() + target = root / CI_WORKFLOW + text = target.read_text(encoding="utf-8") + self.assertIn(exact, text) + target.write_text( + text.replace(exact, replacement, 1), + encoding="utf-8", + ) + self.assert_has_failure(root, failure) + + wrong_key = ( + " key: registryctl-tutorial-${{ runner.os }}-" + "${{ hashFiles('Cargo.lock') }}" + ) + root = self.fixture() + target = root / CI_WORKFLOW + text = target.read_text(encoding="utf-8") + target.write_text( + text.replace( + exact, + wrong_key + + "\n - uses: example.invalid/cache@v1\n" + " with:\n" + + exact, + 1, + ), + encoding="utf-8", + ) + self.assert_has_failure( + root, + "missing registryctl tutorial builder cache key", + ) + + root = self.fixture() + target = root / CI_WORKFLOW + text = target.read_text(encoding="utf-8") + target.write_text( + text.replace( + exact, + exact + + "\n - run: true\n" + " env:\n" + " restore-keys: belongs-to-next-step", + 1, + ), + encoding="utf-8", + ) + self.assertEqual([], self.module.check_repository(root)) + + def test_every_runtime_stays_distroless_and_shell_free(self) -> None: + marker = f"FROM {self.module.DISTROLESS_RUNTIME} AS runtime" + mutable_runtime = "FROM debian:trixie-slim AS runtime" + healthcheck = ( + "HEALTHCHECK --interval=30s --timeout=5s " + "--start-period=10s --retries=3" + ) + for relative in self.module.DOCKERFILES: + with self.subTest(relative=relative, invariant="distroless"): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + self.assertIn(marker, text) + target.write_text( + text.replace(marker, mutable_runtime, 1), + encoding="utf-8", + ) + self.assert_has_failure( + root, + f"{relative}: Dockerfile stage sequence must be exactly", + ) + + with self.subTest(relative=relative, invariant="runtime"): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + self.assertIn(marker, text) + target.write_text( + text.replace(marker, marker + "\nRUN true", 1), + encoding="utf-8", + ) + self.assert_has_failure( + root, + f"{relative}: final Distroless runtime contains 'RUN'", + ) + + with self.subTest(relative=relative, invariant="healthcheck"): + root = self.fixture() + target = root / relative + text = target.read_text(encoding="utf-8") + self.assertIn(healthcheck, text) + target.write_text( + text.replace(healthcheck, "HEALTHCHECK --none", 1), + encoding="utf-8", + ) + self.assert_has_failure( + root, + f"{relative}: missing binary healthcheck", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index a050580f..a82e3e20 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -146,6 +146,23 @@ def test_missing_release_planning_command_tests_are_reported(self) -> None: ) self.assertIn("Release planning command tests", self.module.missing_gates(text)) + def test_missing_debian13_image_contract_is_reported(self) -> None: + text = self.workflow.replace( + "run: python3 release/scripts/check-debian13-images.py", + "run: python3 release/scripts/skip-debian13-images.py", + ) + self.assertIn("Debian 13 image contract", self.module.missing_gates(text)) + + def test_missing_debian13_checker_tests_are_reported(self) -> None: + text = self.workflow.replace( + "run: python3 -m unittest release/scripts/test_check_debian13_images.py", + "run: true", + ) + self.assertIn( + "Debian 13 image contract checker tests", + self.module.missing_gates(text), + ) + def test_missing_release_image_oci_checker_tests_are_reported(self) -> None: text = self.workflow.replace( "run: python3 -m unittest release/scripts/test_check_release_image_oci_labels.py",