diff --git a/.github/workflows/nightly-security.yml b/.github/workflows/nightly-security.yml index 35ac91c9b..45adc947a 100644 --- a/.github/workflows/nightly-security.yml +++ b/.github/workflows/nightly-security.yml @@ -38,6 +38,7 @@ jobs: # the same tree by anyone following the local-run docs, so baking key or # credential material into it would leak just as widely. release_paths = [ + Path("release/docker/Dockerfile.discovery"), Path("release/docker/Dockerfile.evidence"), Path("release/docker/Dockerfile.mint"), Path("release/docker/Dockerfile.relay"), diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 228c911e9..fc9554f01 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -69,7 +69,7 @@ jobs: PY printf '{"spdxVersion":"SPDX-2.3","name":"release-canary"}\n' \ > canary/bundle-root/registry-stack-${tag}.sbom.spdx.json - image_names=(relay evidence mint) + image_names=(relay evidence mint discovery) write_image_reports() { local name="$1" @@ -105,6 +105,7 @@ jobs: schema_version:"registry-stack.advisory-verdict.v2", verdict:"passed", subjects:[ + "discovery-image", "evidence-image", "mint-image", "relay-image" diff --git a/.github/workflows/release-repeatability.yml b/.github/workflows/release-repeatability.yml index 92cccf9f4..f047b14d9 100644 --- a/.github/workflows/release-repeatability.yml +++ b/.github/workflows/release-repeatability.yml @@ -170,6 +170,7 @@ jobs: raise SystemExit(f"expected one release manifest for {version}") artifacts = matches[0].get("artifacts", {}) binary_names = { + "discovery": "discovery", "evidence": "evidence", "evidencectl": "evidencectl", "evidence-oid4vci": "evidence-oid4vci", diff --git a/Cargo.lock b/Cargo.lock index 6184e8854..a70a7305a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3009,6 +3009,7 @@ dependencies = [ "futures", "http", "registry-discovery-profile", + "registry-platform-buildinfo", "registry-platform-canonical-json", "registry-platform-httpsec", "serde", diff --git a/crates/registry-discovery/Cargo.toml b/crates/registry-discovery/Cargo.toml index d2510b988..9914aa263 100644 --- a/crates/registry-discovery/Cargo.toml +++ b/crates/registry-discovery/Cargo.toml @@ -22,6 +22,7 @@ server = [ "dep:axum", "dep:clap", "dep:http", + "dep:registry-platform-buildinfo", "dep:registry-platform-httpsec", "dep:serde_yaml_ng", "dep:tokio", @@ -35,6 +36,7 @@ axum = { workspace = true, optional = true } clap = { workspace = true, optional = true } http = { workspace = true, optional = true } registry-discovery-profile.workspace = true +registry-platform-buildinfo = { workspace = true, optional = true } registry-platform-canonical-json.workspace = true registry-platform-httpsec = { workspace = true, features = ["server"], optional = true } serde.workspace = true diff --git a/crates/registry-discovery/src/main.rs b/crates/registry-discovery/src/main.rs index 75a937126..4367c34c3 100644 --- a/crates/registry-discovery/src/main.rs +++ b/crates/registry-discovery/src/main.rs @@ -11,7 +11,8 @@ use tracing_subscriber::prelude::*; #[derive(Debug, Parser)] #[command( name = "discovery", - about = "Serve one immutable Registry Discovery index" + about = "Serve one immutable Registry Discovery index", + version = registry_platform_buildinfo::DISPLAY_VERSION )] struct Arguments { #[arg(long, value_name = "FILE")] diff --git a/crates/registry-discovery/tests/version_cli.rs b/crates/registry-discovery/tests/version_cli.rs new file mode 100644 index 000000000..37659dd8f --- /dev/null +++ b/crates/registry-discovery/tests/version_cli.rs @@ -0,0 +1,44 @@ +#![cfg(feature = "server")] + +use std::process::Command; + +fn version_output(flag: &str) -> String { + let output = Command::new(env!("CARGO_BIN_EXE_discovery")) + .arg(flag) + .output() + .unwrap_or_else(|err| panic!("discovery {flag} runs: {err}")); + + assert!( + output.status.success(), + "discovery {flag} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +#[test] +fn version_output_uses_user_facing_command_name() { + for flag in ["--version", "-V"] { + assert_eq!( + version_output(flag), + format!( + "discovery {}\n", + registry_platform_buildinfo::DISPLAY_VERSION + ) + ); + } +} + +#[test] +fn version_output_marks_a_build_that_is_not_a_release() { + let expected = if registry_platform_buildinfo::IS_RELEASE_BUILD { + env!("CARGO_PKG_VERSION").to_owned() + } else { + format!("{}-dev", env!("CARGO_PKG_VERSION")) + }; + + assert_eq!( + version_output("--version"), + format!("discovery {expected}\n") + ); +} diff --git a/docs/site/src/content/docs/reference/api-stability.mdx b/docs/site/src/content/docs/reference/api-stability.mdx index 1cb433d99..adca1be11 100644 --- a/docs/site/src/content/docs/reference/api-stability.mdx +++ b/docs/site/src/content/docs/reference/api-stability.mdx @@ -52,7 +52,7 @@ The enforcement column names the repository CI checks so the mechanism is audita | Configuration formats and documented environment variables | Relay's `registry.yaml` and `runtime.yaml` grammars, the frozen Evidence Gateway schemas `products/evidence/contracts/runtime.schema.yaml` and `products/evidence/contracts/bundle.schema.yaml`, plus the [environment variable reference](../environment-variables/) | Relay and Evidence Gateway both parse with `deny_unknown_fields`, so a retired or misspelled key is a startup refusal rather than an ignored field; Evidence Gateway's config parser is tested against the frozen contract schemas (`crates/registry-evidence/src/config.rs`), and `products/evidence/scripts/check-config-key-paths.sh` holds its configuration reference in exact parity with them | | Registry Manifest schema and rendered artifacts | `registry-manifest/v1` and the rendered artifact schema versions, governed by [RS-DM-MANIFEST](../../spec/rs-dm-manifest/) | `validate_manifest` accepts only `registry-manifest/v1`; REQ-DM-MANIFEST-013 requires strict unknown-key rejection at parse time | | Command-line interfaces | Documented commands and flags of `relay`, `evidence`, and `registry-manifest`, and their machine-readable output modes | CLI reference pages; the `evidence` command surface is stated in `products/evidence/OPERATOR-CONTRACT.md` and exercised end to end against the built binary in `crates/registry-evidence/tests/cli.rs`; the release candidate workflow asserts each built binary reports the release version | -| Release artifacts and verification interface | Released binary asset names, the released `ghcr.io/registrystack/relay`, `ghcr.io/registrystack/evidence`, and `ghcr.io/registrystack/mint` container images, and the signature and provenance layout in `release/VERIFY.md` | Release workflow; signed assets and exact image digests verified as documented in [SECURITY.md](https://github.com/registrystack/registry-stack/blob/v0.21.0/SECURITY.md) | +| Release artifacts and verification interface | Released binary asset names, the released `ghcr.io/registrystack/relay`, `ghcr.io/registrystack/evidence`, and `ghcr.io/registrystack/mint` container images, joined by `ghcr.io/registrystack/discovery` from `v0.24.0`, and the signature and provenance layout in `release/VERIFY.md` | Release workflow; signed assets and exact image digests verified as documented in [SECURITY.md](https://github.com/registrystack/registry-stack/blob/v0.21.0/SECURITY.md) | Relay's covered HTTP surface is its route inventory, not the content of any one deployment's OpenAPI document. Adding a resource, an operation, or a statistical dataset to a Registry contract diff --git a/release/OPERATIONS.md b/release/OPERATIONS.md index 388d9574d..f09b039cc 100644 --- a/release/OPERATIONS.md +++ b/release/OPERATIONS.md @@ -34,9 +34,9 @@ container package as private, so publish a clearly non-release bootstrap artifact without putting a token on the command line: ```sh -package="${PACKAGE:?set PACKAGE to relay, evidence, or mint}" +package="${PACKAGE:?set PACKAGE to relay, evidence, mint, or discovery}" case "${package}" in - relay|evidence|mint) ;; + relay|evidence|mint|discovery) ;; *) echo "unsupported release image package: ${package}" >&2; exit 1 ;; esac @@ -58,10 +58,11 @@ oras push \ In the organization package settings, change only the selected package to public and grant `registrystack/registry-stack` Actions access with Write. Starting with `v0.21.0`, the release requires public `relay`, `evidence`, and -`mint` packages. Verify all three before candidate dispatch: +`mint` packages, joined by `discovery` from `v0.24.0`. Verify all four before +candidate dispatch: ```sh -for package in relay evidence mint; do +for package in relay evidence mint discovery; do gh api "/orgs/registrystack/packages/container/${package}" \ --jq '[.name,.package_type,.visibility]' done @@ -72,6 +73,27 @@ Each result must name the requested package and report `container` and then remove only that bootstrap version. This is a package-identity setup step, not part of later releases. +A new release image also needs its own reviewed advisory baseline at +`release/security/-advisory-baseline.json` before its first candidate. +The candidate refuses to run without that file, and the pinned Debian 13 +runtime carries findings that a baseline with no exception cannot clear. Author +it from a real candidate image, never by copying another service's file: run +the candidate once to publish the private candidate image, regenerate the +scanner and rootfs evidence with the procedure in "Renew an image advisory +fingerprint" below, and record the reviewed runtime block, exception set, +owner, and expiry from that evidence. `discovery` is the first image to need +this since the baselines were introduced. + +Enrol the new candidate package in the daily cleanup only after that first +candidate publishes `ghcr.io/registrystack/-candidate`. The cleanup lists +exactly the names in `CANDIDATE_PACKAGES` in +`release/scripts/cleanup-release-candidates.py` and fails closed on a package it +cannot list, so naming an unpublished package would abort the whole scheduled +run. Add `discovery-candidate` to that allowlist, with a matching fixture in +`release/scripts/test_cleanup_release_candidates.py`, as part of the `v0.24.0` +release. The public `discovery` name is already on the `PUBLIC_PACKAGES` +denylist, so cleanup can never reach a released image. + ### Provision client registries Registry Stack v0.22.0 promotes the exact candidate Evidence and Relay client @@ -275,7 +297,7 @@ evidence with the scanner versions pinned in the candidate workflow: ```sh run_id= run_attempt= -name=relay # or evidence or mint +name=relay # or evidence, mint, or discovery candidate_tag="ghcr.io/registrystack/${name}-candidate:candidate-${run_id}-${run_attempt}" digest="$(crane digest "${candidate_tag}")" candidate_ref="ghcr.io/registrystack/${name}-candidate@${digest}" diff --git a/release/VERIFY.md b/release/VERIFY.md index 0df151d43..51a6e3327 100644 --- a/release/VERIFY.md +++ b/release/VERIFY.md @@ -71,8 +71,9 @@ Inspect the compact public release manifest: manifest="registry-stack-${tag}-release-manifest.json" jq -e --arg tag "${tag}" ' - (if ($tag | test("^v0\\.(19|20)\\.")) - then ["relay"] else ["evidence", "mint", "relay"] end) as $image_names | + (if ($tag | test("^v0\\.(19|20)\\.")) then ["relay"] + elif ($tag | test("^v0\\.(21|22|23)\\.")) then ["evidence", "mint", "relay"] + else ["discovery", "evidence", "mint", "relay"] end) as $image_names | .schema_version == "registry-stack.release-candidate.v2" and .repository == "registrystack/registry-stack" and .release.tag == $tag and @@ -91,13 +92,14 @@ jq -e --arg tag "${tag}" ' ``` Starting with `v0.21.0`, the exact image set is Evidence Gateway, Registry -Mint, and Registry Relay. The final release tags recorded in the manifest -must resolve to the same digests as their candidate bindings: +Mint, and Registry Relay; starting with `v0.24.0`, Registry Discovery joins +them. The final release tags recorded in the manifest must resolve to the same +digests as their candidate bindings: ```sh while IFS=$'\t' read -r name digest final_ref; do case "${name}" in - evidence|mint|relay) ;; + discovery|evidence|mint|relay) ;; *) echo "unexpected release image: ${name}" >&2; exit 1 ;; esac test "$(crane digest "${final_ref}")" = "${digest}" @@ -127,8 +129,9 @@ tar -tzf "${evidence}" ``` Starting with `v0.21.0`, the archive contains image-specific SPDX and Syft -reports and Grype reports for `evidence`, `mint`, and `relay`; `v0.19.x` and -`v0.20.x` archives contain those reports for `relay` only. The archive also +reports and Grype reports for `evidence`, `mint`, and `relay`, joined by +`discovery` from `v0.24.0`; `v0.19.x` and `v0.20.x` archives contain those +reports for `relay` only. The archive also contains the advisory verdict used for candidate acceptance. Each report names the exact candidate digest that was promoted. The archive hash is covered by the authenticated checksum chain. The candidate workflow also evaluates any diff --git a/release/docker/Dockerfile.discovery b/release/docker/Dockerfile.discovery new file mode 100644 index 000000000..7e2eea48f --- /dev/null +++ b/release/docker/Dockerfile.discovery @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + +ARG SOURCE_DATE_EPOCH=0 + +FROM debian:trixie-slim@sha256:3a39a0592364683e6bab97937b72cad5a8fa6dcbbee90edb3bb48c7f8e94f258 AS runtime-root +ARG SOURCE_DATE_EPOCH + +# Discovery serves one immutable index and writes nothing, so the image +# pre-owns no managed state or audit directory. The runtime file and the index +# it names are startup-only deployment artifacts mounted read-only together +# under /etc/registry-discovery: the index path is resolved relative to the +# runtime file's own directory. +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-discovery \ + /workspace/runtime-root/licenses/discovery \ + /workspace/runtime-root/usr/local/bin \ + && install -m 0755 /workspace/image-bin/discovery /workspace/runtime-root/usr/local/bin/discovery \ + && install -m 0644 /workspace/LICENSE /workspace/runtime-root/licenses/discovery/LICENSE \ + && find /workspace/runtime-root -exec touch -h --date="@${SOURCE_DATE_EPOCH}" {} + + +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS runtime + +LABEL org.registrystack.runtime.uid="65532" \ + org.registrystack.runtime.gid="65532" + +COPY --from=runtime-root /workspace/runtime-root/ / + +EXPOSE 8080 + +# Discovery serves GET /health for the platform's HTTP probe. The Distroless +# image has no shell or HTTP client, and Discovery has no healthcheck +# subcommand. It also reads no environment variable, so the command is what +# binds the runtime file. +ENTRYPOINT ["/usr/local/bin/discovery"] +CMD ["--runtime", "/etc/registry-discovery/runtime.yaml"] diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index a357a98cd..c4e75ff40 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -11,6 +11,14 @@ fi version="$1" tag="v${version}" +# The Discovery binary joins the release payload at 0.24.0. A candidate rebuilt +# for an earlier version must stage exactly the assets its recorded inventory +# names, so seal-candidate keeps accepting it. +IFS=. read -r version_major version_minor _version_patch <<<"${version}" +include_discovery=0 +if ((version_major > 0 || version_minor >= 24)); then + include_discovery=1 +fi default_builder_image="rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3" if [[ -n "${RELEASE_BUILDER_IMAGE:-}" && "${RELEASE_BUILDER_IMAGE}" != "${default_builder_image}" ]]; then printf 'RELEASE_BUILDER_IMAGE must remain pinned to %s\n' "${default_builder_image}" >&2 @@ -48,6 +56,7 @@ docker run --rm \ --env CARGO_INCREMENTAL=0 \ --env CARGO_TERM_COLOR="${CARGO_TERM_COLOR:-always}" \ --env HOME=/workspace \ + --env RELEASE_INCLUDE_DISCOVERY="${include_discovery}" \ --env RELEASE_TAG="${tag}" \ --env REGISTRY_RELEASE_TAG="${tag}" \ --env RELEASE_RUSTFLAGS="${release_rustflags}" \ @@ -83,41 +92,50 @@ docker run --rm \ cp target/release/evidence-oid4vci "dist/bin/evidence-oid4vci-${RELEASE_TAG}-linux-amd64" cp target/release/evidence dist/image-bin/evidence cp target/release/mint dist/image-bin/mint + + if [[ "${RELEASE_INCLUDE_DISCOVERY}" -eq 1 ]]; then + cargo build --release --locked \ + -p registry-discovery \ + --bin discovery + cp target/release/discovery "dist/bin/discovery-${RELEASE_TAG}-linux-amd64" + cp target/release/discovery dist/image-bin/discovery + fi ' printf '%s\n' "${release_builder_image}" > "${repo_root}/dist/image-bin/RELEASE_BUILDER_IMAGE" -chmod 0755 \ - "${repo_root}/dist/bin/registry-manifest-${tag}-linux-amd64" \ - "${repo_root}/dist/bin/relay-${tag}-linux-amd64" \ - "${repo_root}/dist/bin/relayctl-${tag}-linux-amd64" \ - "${repo_root}/dist/bin/evidence-${tag}-linux-amd64" \ - "${repo_root}/dist/bin/evidencectl-${tag}-linux-amd64" \ - "${repo_root}/dist/bin/mint-${tag}-linux-amd64" \ - "${repo_root}/dist/bin/evidence-oid4vci-${tag}-linux-amd64" \ - "${repo_root}/dist/image-bin/evidence" \ - "${repo_root}/dist/image-bin/mint" \ - "${repo_root}/dist/image-bin/relay" +# The staged asset lists follow the same gate as the build above, so a version +# that predates an asset neither checksums nor chmods a file it never built. +bin_assets=() +image_bin_binaries=() +if [[ "${include_discovery}" -eq 1 ]]; then + bin_assets+=("discovery-${tag}-linux-amd64") + image_bin_binaries+=(discovery) +fi +bin_assets+=( + "evidence-${tag}-linux-amd64" + "evidencectl-${tag}-linux-amd64" + "mint-${tag}-linux-amd64" + "evidence-oid4vci-${tag}-linux-amd64" + "registry-manifest-${tag}-linux-amd64" + "relay-${tag}-linux-amd64" + "relayctl-${tag}-linux-amd64" +) +image_bin_binaries+=(evidence mint relay) + +for asset in "${bin_assets[@]}"; do + chmod 0755 "${repo_root}/dist/bin/${asset}" +done +for asset in "${image_bin_binaries[@]}"; do + chmod 0755 "${repo_root}/dist/image-bin/${asset}" +done ( cd -- "${repo_root}/dist/bin" - sha256sum -- \ - "evidence-${tag}-linux-amd64" \ - "evidencectl-${tag}-linux-amd64" \ - "mint-${tag}-linux-amd64" \ - "evidence-oid4vci-${tag}-linux-amd64" \ - "registry-manifest-${tag}-linux-amd64" \ - "relay-${tag}-linux-amd64" \ - "relayctl-${tag}-linux-amd64" \ - > SHA256SUMS + sha256sum -- "${bin_assets[@]}" > SHA256SUMS ) ( cd -- "${repo_root}/dist/image-bin" - sha256sum -- \ - RELEASE_BUILDER_IMAGE \ - evidence \ - mint \ - relay \ - > SHA256SUMS + sha256sum -- RELEASE_BUILDER_IMAGE "${image_bin_binaries[@]}" > SHA256SUMS ) printf 'built release binaries for %s with canonical container paths\n' "${tag}" diff --git a/release/scripts/build-release-image.sh b/release/scripts/build-release-image.sh index c3052bf66..0a452e958 100755 --- a/release/scripts/build-release-image.sh +++ b/release/scripts/build-release-image.sh @@ -24,7 +24,7 @@ release_image_context="${RELEASE_IMAGE_CONTEXT:-${repo_root}}" created_builder=false case "${name}" in - evidence|mint|relay) + discovery|evidence|mint|relay) dockerfile="${repo_root}/release/docker/Dockerfile.${name}" ;; *) diff --git a/release/scripts/check-debian13-images.py b/release/scripts/check-debian13-images.py index c0c148c3b..edcbdad4f 100755 --- a/release/scripts/check-debian13-images.py +++ b/release/scripts/check-debian13-images.py @@ -33,6 +33,7 @@ DISTROLESS_REPOSITORY = DISTROLESS_RUNTIME.split("@", 1)[0] DOCKERFILES = ( + Path("release/docker/Dockerfile.discovery"), Path("release/docker/Dockerfile.evidence"), Path("release/docker/Dockerfile.mint"), Path("release/docker/Dockerfile.relay"), @@ -90,7 +91,15 @@ ENTRYPOINT ["/usr/local/bin/relay"] CMD ["serve", "--runtime", "/etc/relay/runtime.yaml"] """ +# Each entry pins the runtime instructions that bind one HTTP-probed service to +# its configuration. Discovery reads no environment variable, so it declares no +# `environment` and binds its runtime file through the command instead. HTTP_PROBE_DOCKERFILES = { + Path("release/docker/Dockerfile.discovery"): { + "binary": "discovery", + "entrypoint": 'ENTRYPOINT ["/usr/local/bin/discovery"]', + "command": 'CMD ["--runtime", "/etc/registry-discovery/runtime.yaml"]', + }, Path("release/docker/Dockerfile.evidence"): { "binary": "evidence", "environment": "ENV REGISTRY_EVIDENCE_RUNTIME=/etc/registry-evidence/runtime.yaml", @@ -398,13 +407,21 @@ def check_repository(root: Path = ROOT) -> list[str]: failures, ) for key in ("environment", "entrypoint", "command"): + expected = contract.get(key) + if expected is None: + continue require( runtime, - contract[key], + expected, relative, f"fixed {binary} {key}", failures, ) + if "environment" not in contract and "\nENV " in f"\n{runtime}": + failures.append( + f"{relative}: {binary} binds its configuration through the " + "command, so its runtime must declare no runtime environment" + ) if "HEALTHCHECK" in runtime: failures.append( f"{relative}: HTTP-probed runtime must not carry a binary HEALTHCHECK" diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 8b0371db7..2885295d8 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -641,8 +641,9 @@ "Candidate cleanup exact package allowlist", "release/scripts/cleanup-release-candidates.py", ( - 'CANDIDATE_PACKAGES = (\n "evidence-candidate",\n "mint-candidate",\n "relay-candidate",\n)', + 'CANDIDATE_PACKAGES = (\n # Listing an absent package fails closed, so a candidate name joins this\n # allowlist with the release that first publishes it.\n "evidence-candidate",\n "mint-candidate",\n "relay-candidate",\n)', 'PUBLIC_PACKAGES = (\n # Retired public names stay denylisted so cleanup can never delete history.', + ' "discovery",\n', ' "evidence",\n', ' "mint",\n', ' "relay",\n', diff --git a/release/scripts/cleanup-release-candidates.py b/release/scripts/cleanup-release-candidates.py index 08c604f59..8bbd9a2f2 100755 --- a/release/scripts/cleanup-release-candidates.py +++ b/release/scripts/cleanup-release-candidates.py @@ -19,6 +19,8 @@ OWNER = "registrystack" RETENTION_DAYS = 8 CANDIDATE_PACKAGES = ( + # Listing an absent package fails closed, so a candidate name joins this + # allowlist with the release that first publishes it. "evidence-candidate", "mint-candidate", "relay-candidate", @@ -27,6 +29,7 @@ # Retired public names stay denylisted so cleanup can never delete history. "registry-notary", "registry-relay", + "discovery", "evidence", "mint", "relay", diff --git a/release/scripts/registry-release b/release/scripts/registry-release index 247f5e172..07ffbfc88 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -48,6 +48,7 @@ RELAY_V2_ARTIFACT_INVENTORY = { "evidence-client-node", "evidence-client-python", } +DISCOVERY_RUNTIME_MINIMUM_VERSION = (0, 24, 0) RELEASE_PLAN_SCHEMA = "registry-release.plan.v1" RELEASE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") STRICT_SEMVER = re.compile( @@ -159,6 +160,8 @@ def artifact_inventory_errors(version: str, artifacts: dict[Any, Any]) -> list[s expected_inventory.update({"relay-client-node", "relay-client-python"}) if parsed_version >= (0, 23, 0): expected_inventory.update({"discovery-client-node", "discovery-client-python"}) + if parsed_version >= DISCOVERY_RUNTIME_MINIMUM_VERSION: + expected_inventory.add("discovery") missing = sorted(expected_inventory - artifact_names) unexpected = sorted(artifact_names - expected_inventory) if missing or unexpected: diff --git a/release/scripts/release_candidate.py b/release/scripts/release_candidate.py index 29e66565b..2ed6fd701 100644 --- a/release/scripts/release_candidate.py +++ b/release/scripts/release_candidate.py @@ -45,6 +45,8 @@ OFFICIAL_RUNTIME_IMAGE_NAMES = {"evidence", "mint", "relay"} CLIENT_REGISTRY_PACKAGE_MINIMUM_VERSION = (0, 21, 1) DISCOVERY_CLIENT_PACKAGE_MINIMUM_VERSION = (0, 23, 0) +DISCOVERY_RUNTIME_MINIMUM_VERSION = (0, 24, 0) +DISCOVERY_RUNTIME_IMAGE_NAMES = OFFICIAL_RUNTIME_IMAGE_NAMES | {"discovery"} V2_TOP_LEVEL_FIELDS = { "schema_version", "repository", @@ -105,7 +107,9 @@ def _candidate_image_names(version: str) -> set[str]: ) if parsed < OFFICIAL_RUNTIME_IMAGE_MINIMUM_VERSION: return HISTORICAL_RUNTIME_IMAGE_NAMES - return OFFICIAL_RUNTIME_IMAGE_NAMES + if parsed < DISCOVERY_RUNTIME_MINIMUM_VERSION: + return OFFICIAL_RUNTIME_IMAGE_NAMES + return DISCOVERY_RUNTIME_IMAGE_NAMES def _version_uses_release_docs(version: tuple[int, int, int]) -> bool: @@ -200,6 +204,8 @@ def _relay_v2_payload_inventory(version: str) -> dict[str, str]: inventory[ f"registrystack-discovery-client-{platform}-{version}.tgz" ] = "client-package" + if version_tuple >= DISCOVERY_RUNTIME_MINIMUM_VERSION: + inventory[f"discovery-{tag}-linux-amd64"] = "binary" return inventory diff --git a/release/scripts/smoke-release-image-oci-labels.sh b/release/scripts/smoke-release-image-oci-labels.sh index 9d58a403c..24b399140 100755 --- a/release/scripts/smoke-release-image-oci-labels.sh +++ b/release/scripts/smoke-release-image-oci-labels.sh @@ -6,7 +6,7 @@ repo_root="$(cd -- "${script_dir}/../.." && pwd)" checker="${script_dir}/check-release-image-oci-labels.py" image_builder="${script_dir}/build-release-image.sh" layout_comparator="${script_dir}/compare-release-image-layouts.py" -images=(relay evidence mint) +images=(relay evidence mint discovery) relay_dockerfile="${repo_root}/release/docker/Dockerfile.relay" source_label="https://github.com/registrystack/registry-stack" diff --git a/release/scripts/test_check_debian13_images.py b/release/scripts/test_check_debian13_images.py index 5a8377e4d..2ea224903 100644 --- a/release/scripts/test_check_debian13_images.py +++ b/release/scripts/test_check_debian13_images.py @@ -54,6 +54,7 @@ def test_relay_v2_image_is_a_required_maintained_surface(self) -> None: def test_official_runtime_images_are_required_maintained_surfaces(self) -> None: self.assertEqual( { + Path("release/docker/Dockerfile.discovery"), Path("release/docker/Dockerfile.evidence"), Path("release/docker/Dockerfile.mint"), Path("release/docker/Dockerfile.relay"), @@ -62,6 +63,7 @@ def test_official_runtime_images_are_required_maintained_surfaces(self) -> None: ) self.assertEqual( { + Path("release/docker/Dockerfile.discovery"), Path("release/docker/Dockerfile.evidence"), Path("release/docker/Dockerfile.mint"), }, @@ -69,7 +71,44 @@ def test_official_runtime_images_are_required_maintained_surfaces(self) -> None: ) def test_http_probed_images_bind_fixed_config_and_entrypoint(self) -> None: + # Discovery reads no environment variable, so its configuration binding + # is the command; the others bind it through the environment. + wrong = { + "environment": "ENV WRONG_CONFIG=/tmp/config.yaml", + "command": 'CMD ["--runtime", "/tmp/runtime.yaml"]', + } for relative, contract in POLICY.HTTP_PROBE_DOCKERFILES.items(): + key = "environment" if "environment" in contract else "command" + with self.subTest(relative=relative, key=key): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.repository_copy(root) + dockerfile = root / relative + dockerfile.write_text( + dockerfile.read_text(encoding="utf-8").replace( + contract[key], + wrong[key], + ), + encoding="utf-8", + ) + failures = POLICY.check_repository(root) + self.assertTrue( + any( + str(relative) in failure + and f"fixed {contract['binary']} {key}" in failure + for failure in failures + ), + failures, + ) + + def test_image_without_an_environment_contract_declares_no_environment( + self, + ) -> None: + # An unbound ENV would be a second configuration source the contract + # does not describe, so declaring no environment must mean carrying none. + for relative, contract in POLICY.HTTP_PROBE_DOCKERFILES.items(): + if "environment" in contract: + continue with self.subTest(relative=relative): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -77,8 +116,9 @@ def test_http_probed_images_bind_fixed_config_and_entrypoint(self) -> None: dockerfile = root / relative dockerfile.write_text( dockerfile.read_text(encoding="utf-8").replace( - contract["environment"], - "ENV WRONG_CONFIG=/tmp/config.yaml", + contract["entrypoint"], + "ENV SMUGGLED_CONFIG=/tmp/config.yaml\n" + + contract["entrypoint"], ), encoding="utf-8", ) @@ -86,7 +126,7 @@ def test_http_probed_images_bind_fixed_config_and_entrypoint(self) -> None: self.assertTrue( any( str(relative) in failure - and f"fixed {contract['binary']} environment" in failure + and "must declare no runtime environment" in failure for failure in failures ), failures, diff --git a/release/scripts/test_check_release_image_oci_labels.py b/release/scripts/test_check_release_image_oci_labels.py index 748cbbf7f..a96b170d1 100644 --- a/release/scripts/test_check_release_image_oci_labels.py +++ b/release/scripts/test_check_release_image_oci_labels.py @@ -620,7 +620,7 @@ def read_calls(path: Path) -> list[list[str]]: build_calls = [ call for call in read_calls(docker_log) if call[:2] == ["buildx", "build"] ] - self.assertEqual(8, len(build_calls)) + self.assertEqual(10, len(build_calls)) dockerfiles = [] for call in build_calls: self.assertEqual(["buildx", "build"], call[:2]) @@ -645,6 +645,7 @@ def read_calls(path: Path) -> list[list[str]]: self.assertEqual( { + str(ROOT / "release/docker/Dockerfile.discovery"), str(ROOT / "release/docker/Dockerfile.evidence"), str(ROOT / "release/docker/Dockerfile.mint"), str(ROOT / "release/docker/Dockerfile.relay"), @@ -657,7 +658,7 @@ def read_calls(path: Path) -> list[list[str]]: str(ROOT / "release/docker/Dockerfile.relay") ), ) - for name in ("evidence", "mint"): + for name in ("discovery", "evidence", "mint"): self.assertEqual( 2, dockerfiles.count( @@ -674,6 +675,7 @@ def read_calls(path: Path) -> list[list[str]]: } self.assertEqual( { + "correct-discovery-first", "correct-evidence-first", "correct-mint-first", "correct-relay-first", @@ -699,7 +701,7 @@ def read_calls(path: Path) -> list[list[str]]: for call in python_calls if call and call[0].endswith("compare-release-image-layouts.py") ] - self.assertEqual(4, len(comparisons)) + self.assertEqual(5, len(comparisons)) self.assertEqual(1, sum("--rootfs-only" in call for call in comparisons)) diff --git a/release/scripts/test_cleanup_release_candidates.py b/release/scripts/test_cleanup_release_candidates.py index 4a6a7e7d5..de03d5592 100644 --- a/release/scripts/test_cleanup_release_candidates.py +++ b/release/scripts/test_cleanup_release_candidates.py @@ -95,6 +95,7 @@ def test_public_and_unknown_packages_are_rejected_before_listing(self) -> None: for package in ( "registry-notary", "registry-relay", + "discovery", "evidence", "mint", "relay", diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 4245023da..e6a8d20a0 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -1191,9 +1191,10 @@ def test_release_canary_exercises_the_current_image_contract(self) -> None: for current in ( "_relay_v2_payload_inventory", "payloads: $payloads[0]", - "image_names=(relay evidence mint)", + "image_names=(relay evidence mint discovery)", "images: $images[0]", "scans: $scans[0]", + '"discovery-image"', '"evidence-image"', '"mint-image"', '"relay-image"', @@ -1263,7 +1264,7 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: name: (ROOT / f"release/docker/Dockerfile.{name}").read_text( encoding="utf-8" ) - for name in ("evidence", "mint", "relay") + for name in ("discovery", "evidence", "mint", "relay") } self.assertIn("-p registry-manifest-cli", binary_recipe) @@ -1271,7 +1272,9 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: self.assertIn("--bin relay", binary_recipe) self.assertIn("--no-default-features", binary_recipe) self.assertIn("-p registry-relayctl", binary_recipe) - for artifact in ("registry-manifest", "relay", "relayctl"): + self.assertIn("-p registry-discovery", binary_recipe) + self.assertIn("--bin discovery", binary_recipe) + for artifact in ("discovery", "registry-manifest", "relay", "relayctl"): self.assertIn( f'"dist/bin/{artifact}-${{RELEASE_TAG}}-linux-amd64"', binary_recipe, @@ -1279,7 +1282,7 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: self.assertNotIn("-p registryctl ", binary_recipe) self.assertNotIn("-p registry-relay ", binary_recipe) self.assertNotIn("registry-relay-rhai-worker", binary_recipe) - for name in ("evidence", "mint", "relay"): + for name in ("discovery", "evidence", "mint", "relay"): self.assertIn( f"cp target/release/{name} dist/image-bin/{name}", binary_recipe, @@ -1289,9 +1292,92 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: f"/workspace/runtime-root/usr/local/bin/{name}", release_dockerfiles[name], ) - self.assertIn("evidence|mint|relay)", image_recipe) + self.assertIn("discovery|evidence|mint|relay)", image_recipe) self.assertNotIn("registry-relay)", image_recipe) + def test_discovery_runtime_artifact_joins_the_inventory_at_v0_24(self) -> None: + module = load_registry_release() + without_discovery = { + name: "0.24.0" + for name in ( + *module.RELAY_V2_ARTIFACT_INVENTORY, + "relay-installer", + "registry-docs", + "relay-client-node", + "relay-client-python", + "discovery-client-node", + "discovery-client-python", + ) + } + + self.assertNotEqual( + [], module.artifact_inventory_errors("0.24.0", without_discovery) + ) + self.assertEqual( + [], + module.artifact_inventory_errors( + "0.24.0", without_discovery | {"discovery": "0.24.0"} + ), + ) + self.assertEqual( + [], + module.artifact_inventory_errors( + "0.23.0", {name: "0.23.0" for name in without_discovery} + ), + ) + self.assertNotEqual( + [], + module.artifact_inventory_errors( + "0.23.0", + {name: "0.23.0" for name in without_discovery} + | {"discovery": "0.23.0"}, + ), + ) + + def test_binary_recipe_stages_discovery_only_from_its_first_release(self) -> None: + # The recipe runs for every supported version, including a rebuilt + # candidate for a version whose recorded inventory predates Discovery. + # Run the recipe's own gate rather than a copy of it. + recipe = (ROOT / "release/scripts/build-release-binaries.sh").read_text( + encoding="utf-8" + ) + start = recipe.index("IFS=. read -r version_major") + end = recipe.index("fi\n", recipe.index("include_discovery=1")) + len("fi\n") + gate = recipe[start:end] + + def include_discovery(version: str) -> str: + completed = subprocess.run( + [ + "bash", + "-c", + "set -euo pipefail\n" + 'version="$1"\n' + f"{gate}" + 'printf "%s" "${include_discovery}"', + "build-release-binaries", + version, + ], + capture_output=True, + text=True, + check=True, + ) + return completed.stdout + + for version in ("0.19.0", "0.21.0", "0.23.0", "0.23.9"): + with self.subTest(version=version): + self.assertEqual("0", include_discovery(version)) + for version in ("0.24.0", "0.24.1", "0.25.0", "1.0.0"): + with self.subTest(version=version): + self.assertEqual("1", include_discovery(version)) + + # The staged asset lists follow the same gate, so an earlier candidate + # neither checksums nor chmods an asset the recipe did not build. + self.assertIn( + 'bin_assets+=("discovery-${tag}-linux-amd64")', + recipe, + ) + self.assertIn("image_bin_binaries+=(discovery)", recipe) + def test_release_packaging_excludes_retired_notary(self) -> None: binary_recipe = (ROOT / "release/scripts/build-release-binaries.sh").read_text( encoding="utf-8" @@ -1325,7 +1411,7 @@ def test_nightly_security_scans_the_exact_release_dockerfile_roster(self) -> Non workflow = (ROOT / ".github/workflows/nightly-security.yml").read_text( encoding="utf-8" ) - for name in ("evidence", "mint", "relay"): + for name in ("discovery", "evidence", "mint", "relay"): self.assertIn( f'Path("release/docker/Dockerfile.{name}")', workflow, @@ -2379,6 +2465,8 @@ def write_manifest( if version_tuple >= (0, 23, 0): artifacts["discovery-client-node"] = version artifacts["discovery-client-python"] = version + if version_tuple >= (0, 24, 0): + artifacts["discovery"] = version manifest = { "stack": { "release": "beta-6", diff --git a/release/scripts/test_registry_release_plans.py b/release/scripts/test_registry_release_plans.py index 918043582..c72d81aa2 100644 --- a/release/scripts/test_registry_release_plans.py +++ b/release/scripts/test_registry_release_plans.py @@ -37,6 +37,7 @@ ) RELAY_CLIENT_PACKAGE_MINIMUM_VERSION = (0, 19, 1) DISCOVERY_CLIENT_PACKAGE_MINIMUM_VERSION = (0, 23, 0) +DISCOVERY_RUNTIME_MINIMUM_VERSION = (0, 24, 0) def run(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: @@ -82,6 +83,8 @@ def manifest(version: str, release_id: str, source_ref: str, status: str) -> dic inventory += ("relay-client-node", "relay-client-python") if version_tuple >= DISCOVERY_CLIENT_PACKAGE_MINIMUM_VERSION: inventory += ("discovery-client-node", "discovery-client-python") + if version_tuple >= DISCOVERY_RUNTIME_MINIMUM_VERSION: + inventory += ("discovery",) data = { "stack": { "release": release_id, diff --git a/release/scripts/test_release_candidate.py b/release/scripts/test_release_candidate.py index ff950f7bb..f0c0f1c4a 100644 --- a/release/scripts/test_release_candidate.py +++ b/release/scripts/test_release_candidate.py @@ -41,7 +41,7 @@ def json_bytes(value: object) -> bytes: def security_evidence_members( - image_names: tuple[str, ...] = ("evidence", "mint", "relay"), + image_names: tuple[str, ...] = ("discovery", "evidence", "mint", "relay"), ) -> dict[str, bytes]: refs = { name: f"ghcr.io/registrystack/{name}-candidate@{IMAGE_DIGEST}" @@ -346,7 +346,7 @@ def replace_security_evidence( def make_v2_candidate(self) -> tuple[dict, Path, Path, dict]: bundle_root = self.root / "v2-bundle" - image_names = ("evidence", "mint", "relay") + image_names = ("discovery", "evidence", "mint", "relay") evidence_members = security_evidence_members(image_names) evidence_name = "registry-stack-v1.2.3-security-evidence.tar.gz" payload_inventory = self.module._relay_v2_payload_inventory("1.2.3") @@ -690,6 +690,13 @@ def test_discovery_client_payloads_begin_with_v0_23_0(self) -> None: current["registrystack-discovery-client-linux-x64-gnu-0.23.0.tgz"], ) + def test_discovery_runtime_payload_begins_with_v0_24_0(self) -> None: + historical = self.module._relay_v2_payload_inventory("0.23.0") + current = self.module._relay_v2_payload_inventory("0.24.0") + + self.assertNotIn("discovery-v0.23.0-linux-amd64", historical) + self.assertEqual("binary", current["discovery-v0.24.0-linux-amd64"]) + def test_v2_security_evidence_members_follow_candidate_images(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -729,10 +736,23 @@ def test_official_runtime_image_roster_begins_at_v0_21(self) -> None: self.module._candidate_image_names("0.21.0"), ) + def test_discovery_runtime_image_joins_the_roster_at_v0_24(self) -> None: + for version in ("0.21.0", "0.22.0", "0.23.0"): + with self.subTest(version=version): + self.assertEqual( + {"evidence", "mint", "relay"}, + self.module._candidate_image_names(version), + ) + self.assertEqual( + {"discovery", "evidence", "mint", "relay"}, + self.module._candidate_image_names("0.24.0"), + ) + def test_image_names_cli_emits_the_version_appropriate_roster(self) -> None: cases = ( ("0.20.2", "relay\n"), ("0.21.0", "evidence mint relay\n"), + ("0.24.0", "discovery evidence mint relay\n"), ) for version, expected in cases: with self.subTest(version=version): @@ -904,7 +924,7 @@ def test_v2_security_evidence_archive_requires_every_expected_file( candidate, _, bundle_root, _ = self.make_v2_candidate() members = security_evidence_members() required = self.module._security_evidence_required_files( - self.module.OFFICIAL_RUNTIME_IMAGE_NAMES + self.module.DISCOVERY_RUNTIME_IMAGE_NAMES ) for missing in sorted(required): with self.subTest(missing=missing): diff --git a/release/scripts/test_release_repeatability_workflow.py b/release/scripts/test_release_repeatability_workflow.py index 52c1ba5c6..95a0c5ade 100644 --- a/release/scripts/test_release_repeatability_workflow.py +++ b/release/scripts/test_release_repeatability_workflow.py @@ -51,6 +51,7 @@ def test_records_the_30_day_silver_claim_boundary(self) -> None: def test_clean_proof_compares_binaries_and_images(self) -> None: self.assertIn("Build canonical Linux payload from clean state", self.workflow) self.assertIn("cmp \"published/${asset}\"", self.workflow) + self.assertIn('"discovery": "discovery"', self.workflow) self.assertIn('"evidence-oid4vci": "evidence-oid4vci"', self.workflow) self.assertIn('"registry-manifest": "registry-manifest"', self.workflow) self.assertIn('"relay": "relay"', self.workflow) diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index f9dfe7bd2..aaf94c5c1 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -330,6 +330,10 @@ def test_current_release_pipeline_has_no_pre_v0_19_surface(self) -> None: {"evidence", "mint", "relay"}, module._candidate_image_names("0.21.0"), ) + self.assertEqual( + {"discovery", "evidence", "mint", "relay"}, + module._candidate_image_names("0.24.0"), + ) self.assertFalse( any("registry-notary" in name for name in module.SECURITY_EVIDENCE_REQUIRED_FILES) )