diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..bc6dc229e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Build context for crates/trusted-server-adapter-axum/Dockerfile. Keep the +# workspace sources; drop history, build output, local secrets and docs. +.git +**/target +**/node_modules +**/dist +.env* +!.env.example +trusted-server.toml +.ts-write-* +js-assets.toml +.edgezero +.claude +.wolf +docs +tinybird diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index fea966265..47b288b66 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -256,4 +256,4 @@ jobs: run: shellcheck scripts/release/phase1-build.sh scripts/release/validate-fastly-package.sh scripts/release/bootstrap.sh scripts/release/build.sh scripts/release/build-js.sh - name: Test release producer scripts - run: node --test scripts/release/phase1.test.mjs + run: node --test scripts/release/phase1.test.mjs scripts/release/packages.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5c38bc6f..960c7b206 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,3 +103,130 @@ jobs: # would exit 0 without publishing. test "$(node --version)" = "v$NODE_VERSION" node publisher/scripts/release/publish.mjs + # Mirrors the published release into GHCR: one OCI artifact per binary with + # its SPDX SBOM and Sigstore bundles. An existing tag is refused unless it + # already holds exactly the release files; GHCR tags stay mutable. + packages: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.PACKAGE_PUBLICATION_ENABLED == 'true' + needs: release + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + # Run only the package publisher and the asset list it imports. + - uses: actions/checkout@v5 + with: + path: publisher + persist-credentials: false + sparse-checkout: | + .tool-versions + scripts/release/publish.mjs + scripts/release/packages.mjs + scripts/release/release-notes.md + sparse-checkout-cone-mode: false + - name: Read pinned Node version + id: tools + run: echo "node=$(awk '$1 == "nodejs" { print $2 }' publisher/.tool-versions)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v5 + with: + node-version: ${{ steps.tools.outputs.node }} + - name: Install the pinned GitHub CLI and oras + env: + GH_VERSION: 2.102.0 + GH_SHA256: bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386 + ORAS_VERSION: 1.3.4 + ORAS_SHA256: f27adb935022d94df8dc77719c322dda592c78a0d57a6f7dcdd8d900b248c454 + run: | + set -euo pipefail + gh_archive="gh_${GH_VERSION}_linux_amd64.tar.gz" + curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/cli/cli/releases/download/v${GH_VERSION}/${gh_archive}" \ + --output "$RUNNER_TEMP/$gh_archive" + printf '%s %s\n' "$GH_SHA256" "$RUNNER_TEMP/$gh_archive" | sha256sum --check + tar -xzf "$RUNNER_TEMP/$gh_archive" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/gh_${GH_VERSION}_linux_amd64/bin" >> "$GITHUB_PATH" + oras_archive="oras_${ORAS_VERSION}_linux_amd64.tar.gz" + curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/${oras_archive}" \ + --output "$RUNNER_TEMP/$oras_archive" + printf '%s %s\n' "$ORAS_SHA256" "$RUNNER_TEMP/$oras_archive" | sha256sum --check + mkdir "$RUNNER_TEMP/oras" + tar -xzf "$RUNNER_TEMP/$oras_archive" -C "$RUNNER_TEMP/oras" oras + echo "$RUNNER_TEMP/oras" >> "$GITHUB_PATH" + - name: Check the release, then push and verify each package + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + SOURCE_COMMIT: ${{ github.sha }} + PACKAGE_PUBLICATION_ENABLED: ${{ vars.PACKAGE_PUBLICATION_ENABLED }} + NODE_VERSION: ${{ steps.tools.outputs.node }} + run: | + set -euo pipefail + test "$(gh --version | head -1 | cut -d' ' -f3)" = 2.102.0 + test "$(oras version | sed -n 's/^Version: *//p')" = 1.3.4 + test "$(node --version)" = "v$NODE_VERSION" + printf '%s' "$GH_TOKEN" | oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + node publisher/scripts/release/packages.mjs + # Builds the Axum server image from the tagged source and pushes it to GHCR + # with GitHub build provenance. It is an ordinary build, outside the + # reproducible release cohort. If a push or attestation fails, delete the + # package version before re-running; see the maintainer checklist. + axum-image: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.PACKAGE_PUBLICATION_ENABLED == 'true' + needs: release + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read + packages: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Build, push and record the image digest + id: image + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + SOURCE_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/trusted-server-axum" + printf '%s' "$GH_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + # GHCR tags are mutable; refuse to replace one at publish time. Only + # the registry's not-found answer counts as absent. + if lookup="$(docker buildx imagetools inspect "$image:$RELEASE_TAG" 2>&1)"; then + echo "Refusing to overwrite $image:$RELEASE_TAG" >&2 + exit 1 + elif ! grep -qF -- "$image:$RELEASE_TAG: not found" <<< "$lookup"; then + echo "Cannot check whether $image:$RELEASE_TAG exists: $lookup" >&2 + exit 1 + fi + docker buildx build \ + --file crates/trusted-server-adapter-axum/Dockerfile \ + --platform linux/amd64 \ + --provenance=false --sbom=false \ + --build-arg "TRUSTED_SERVER_VERSION=$SOURCE_COMMIT" \ + --label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \ + --label "org.opencontainers.image.revision=$SOURCE_COMMIT" \ + --label "org.opencontainers.image.version=$RELEASE_TAG" \ + --label "org.opencontainers.image.description=Trusted Server on Axum $RELEASE_TAG (development status)" \ + --tag "$image:$RELEASE_TAG" \ + --metadata-file "$RUNNER_TEMP/image.json" \ + --push . + digest="$(jq -r '."containerimage.digest"' "$RUNNER_TEMP/image.json")" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + test "$(docker buildx imagetools inspect "$image:$RELEASE_TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')" = "$digest" + echo "name=$image" >> "$GITHUB_OUTPUT" + echo "digest=$digest" >> "$GITHUB_OUTPUT" + - name: Attest the image's build provenance + uses: actions/attest@v4 + with: + subject-name: ${{ steps.image.outputs.name }} + subject-digest: ${{ steps.image.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e39b2d64..8228c564b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -124,6 +124,39 @@ jobs: path: ${{ runner.temp }}/trusted-server-finalize-fastly-*/ if-no-files-found: ignore + axum-image: + name: Axum image smoke + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust toolchain + uses: ./.github/actions/setup-rust + with: + cache-key: cargo-axum-image + + - name: Read pinned Node.js version + id: node-version + shell: bash + run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + + # The smoke builds the `ts` CLI, whose core dependency runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.version }} + + - name: Build the Axum image + run: | + docker buildx build --load \ + --file crates/trusted-server-adapter-axum/Dockerfile \ + --tag trusted-server-axum:smoke . + + - name: Run the Axum smoke against the image + run: AXUM_SMOKE_IMAGE=trusted-server-axum:smoke ./scripts/smoke-axum.sh + test-axum: name: cargo test (axum native) runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 3ad4da3fd..1040cef1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -377,7 +377,7 @@ Every PR must pass: 13. CI Node pin guard: `node --test scripts/check-ci-node-pin.test.mjs && node scripts/check-ci-node-pin.mjs` with the `.tool-versions` Node (every CI job that compiles Rust sets up the `.tool-versions` Node before its first cargo step) 14. Fastly absolute-deadline network fixtures under Viceroy (the harness builds the adapter test binary itself): `node crates/trusted-server-adapter-fastly/tests/deadline-origin.mjs "$(command -v viceroy)"` 15. Worker auction-deadline runtime fixtures (`worker-auction-deadlines.yml`): build and lint the runtime fixture, then run it under the local runtime. For Cloudflare, `cargo build --locked -p trusted-server-adapter-cloudflare --example deadline-runtime --target wasm32-unknown-unknown --features cloudflare` and `node scripts/test-worker-auction-deadlines.mjs cloudflare --runtime "$(command -v wrangler)"` (requires wrangler and the `Cargo.lock` wasm-bindgen CLI). For Spin, `cargo build --locked -p trusted-server-adapter-spin --example deadline-runtime --target wasm32-wasip1 --features spin` and `node scripts/test-worker-auction-deadlines.mjs spin --runtime "$(command -v spin)"`. CI lints each fixture with the same `cargo clippy` arguments plus `-- -D warnings` -16. Release producer helpers: `node --test scripts/release/phase1.test.mjs && shellcheck scripts/release/phase1-build.sh scripts/release/validate-fastly-package.sh scripts/release/bootstrap.sh scripts/release/build.sh scripts/release/build-js.sh` +16. Release producer helpers: `node --test scripts/release/phase1.test.mjs scripts/release/packages.test.mjs && shellcheck scripts/release/phase1-build.sh scripts/release/validate-fastly-package.sh scripts/release/bootstrap.sh scripts/release/build.sh scripts/release/build-js.sh` 17. Native release producer tests and lint: `cargo test --package trusted-server-release --target --locked` and `cargo clippy --package trusted-server-release --target --all-targets --all-features --locked -- -D warnings` (CI uses `x86_64-unknown-linux-gnu`). 18. Native checked/ordinary JS release fixtures: `cargo test --package trusted-server-js --target --locked --test release_prebuilt` and `cargo clippy --package trusted-server-js --target --all-targets --all-features --locked -- -D warnings` (CI uses `x86_64-unknown-linux-gnu`). diff --git a/crates/trusted-server-adapter-axum/Dockerfile b/crates/trusted-server-adapter-axum/Dockerfile new file mode 100644 index 000000000..d07ccada8 --- /dev/null +++ b/crates/trusted-server-adapter-axum/Dockerfile @@ -0,0 +1,69 @@ +# Trusted Server on Axum as a container image. +# +# Build from the repository root: +# docker build --file crates/trusted-server-adapter-axum/Dockerfile \ +# --build-arg TRUSTED_SERVER_VERSION="$(git rev-parse HEAD)" \ +# --tag trusted-server-axum . +# +# The image is an ordinary build with build provenance only. It is not part of +# the reproducible release cohort that the binary release rebuild checks cover. +# Axum is development status; see docs/guide/axum-dev.md. + +# The tsjs build script runs npm; this is the `.tool-versions` Node. +FROM docker.io/library/node:24.20.0-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e AS node + +# Debian 12 builder and runtime share glibc 2.36. +FROM docker.io/library/rust:1.98.1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e AS build + +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm +RUN set -eu; \ + ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm; \ + ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx; \ + test "$(node --version)" = v24.20.0; \ + npm --version + +# Use the installed toolchain as is: rust-toolchain.toml would otherwise make +# rustup download its extra wasm targets, an unpinned network input. +ENV RUSTUP_TOOLCHAIN=1.98.1 + +WORKDIR /src +COPY . . + +# The checkout has no `.git`, so pass the source commit as the embedded +# version, as the release binaries do. +ARG TRUSTED_SERVER_VERSION +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/src/target \ + set -eu; \ + if [ -n "${TRUSTED_SERVER_VERSION:-}" ]; then \ + export TRUSTED_SERVER__GIT_VERSION="$TRUSTED_SERVER_VERSION"; \ + fi; \ + host="$(rustc -vV | sed -n 's/^host: //p')"; \ + cargo build --release --locked --package trusted-server-adapter-axum \ + --bin trusted-server-axum --target "$host"; \ + install -D -m 0755 "target/$host/release/trusted-server-axum" \ + /out/trusted-server-axum + +# Debian slim with CA certificates only: no Node, compiler, config or secrets. +# Trusted Server's origin fetches use compiled-in webpki roots; only EdgeZero's +# own client reads this system store. +FROM docker.io/library/debian:bookworm-slim@sha256:734a72b3d6142a71993b4bc580ca01d8ca687f77a2c422d96a961d3376b179bc + +RUN set -eu; \ + apt-get update; \ + apt-get install --yes --no-install-recommends ca-certificates; \ + rm -rf /var/lib/apt/lists/* + +COPY --from=build /out/trusted-server-axum /usr/local/bin/trusted-server-axum + +# Containers must accept connections from outside their own loopback. The +# process writes nothing, so it runs with a read-only root filesystem. +ENV EDGEZERO__ADAPTER__HOST=0.0.0.0 \ + EDGEZERO__ADAPTER__PORT=8080 +EXPOSE 8080 +# The server shuts down on SIGINT only, and a PID 1 without a SIGTERM handler +# would ignore `docker stop` until the kill timeout. +STOPSIGNAL SIGINT +USER 10001:10001 +ENTRYPOINT ["/usr/local/bin/trusted-server-axum"] diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 66d592e0b..07f10adbe 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,4 +1,5 @@ use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; +use edgezero_core::addr::{BindAddrResolution, resolve_bind_addr}; use edgezero_core::app::Hooks as _; use log::LevelFilter; use trusted_server_adapter_axum::app::TrustedServerApp; @@ -9,6 +10,15 @@ const LOG_LEVEL_ENV: &str = "RUST_LOG"; /// Log level used when `RUST_LOG` is unset or unrecognized. const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info; +/// `EdgeZero` environment variable carrying the listen IP address. +const HOST_ENV: &str = "EDGEZERO__ADAPTER__HOST"; + +/// `EdgeZero` environment variable carrying the listen port. +const EDGEZERO_PORT_ENV: &str = "EDGEZERO__ADAPTER__PORT"; + +/// Environment variable carrying a listen port for local tooling and tests. +const PORT_ENV: &str = "PORT"; + #[allow(clippy::print_stderr)] fn main() { // `var_os` keeps a set but non-UTF-8 value visible, so it is rejected @@ -26,15 +36,24 @@ fn main() { eprintln!("warning: logger init failed: {e}"); } - let config = match port_from_env() { - // When PORT is set, bind to a specific address so integration tests - // can allocate a fresh OS port each run and avoid TIME_WAIT flakiness. - Some(port) => AxumDevServerConfig { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], port)), - enable_ctrl_c: true, - }, - // Normal development path: read bind address from axum.toml. - None => AxumDevServerConfig::default(), + let env = |name: &str| std::env::var_os(name).map(|value| value.to_string_lossy().into_owned()); + let (host, edgezero_port, port) = (env(HOST_ENV), env(EDGEZERO_PORT_ENV), env(PORT_ENV)); + let resolution = match bind_address(host.as_deref(), edgezero_port.as_deref(), port.as_deref()) + { + Ok(resolution) => resolution, + // Silently falling back to another port would surprise tooling + // that expects the server where it asked for it. + Err(message) => { + eprintln!("error: {message}"); + std::process::exit(1); + } + }; + for warning in &resolution.warnings { + log::warn!("{warning}"); + } + let config = AxumDevServerConfig { + addr: resolution.addr, + enable_ctrl_c: true, }; log::info!("Listening on http://{}", config.addr); @@ -65,30 +84,130 @@ fn parse_log_level(configured: Option<&str>) -> Result { } } -/// Read a port number from the `PORT` environment variable. +/// Resolves the listen address with `EdgeZero`'s precedence. /// -/// Returns `None` when the variable is unset. Exits non-zero if the value -/// is set but cannot be parsed — silently falling back to a different port -/// would surprise tooling that expects the server at the requested address. -#[allow(clippy::print_stderr)] -fn port_from_env() -> Option { - let raw = std::env::var("PORT").ok()?; - match raw.parse() { - Ok(port) => Some(port), - Err(e) => { - eprintln!("error: PORT env var '{raw}' is not a valid u16: {e}"); - std::process::exit(1); - } - } +/// `EDGEZERO__ADAPTER__HOST` and `EDGEZERO__ADAPTER__PORT` win, so a container +/// can listen on `0.0.0.0`. `PORT` is the port fallback local tooling uses to +/// allocate a fresh port per run. Otherwise the address is `127.0.0.1:8787`. +/// An invalid `EdgeZero` value falls back with a warning, as `EdgeZero` does. +/// `PORT` is read only when it is the fallback, so a valid +/// `EDGEZERO__ADAPTER__PORT` makes any `PORT` irrelevant. +/// +/// # Errors +/// +/// Returns a message naming `PORT` when it is the fallback and is not a +/// nonzero port number. +fn bind_address( + host: Option<&str>, + edgezero_port: Option<&str>, + port: Option<&str>, +) -> Result { + let edgezero_port_valid = + edgezero_port.is_some_and(|raw| raw.parse::().is_ok_and(|value| value != 0)); + let fallback_port = match port { + Some(raw) if !edgezero_port_valid => match raw.parse::() { + Ok(0) => { + return Err(format!( + "{PORT_ENV}=\"0\" is not supported (would bind to a random OS port)" + )); + } + Ok(value) => Some(value), + Err(error) => return Err(format!("{PORT_ENV}={raw:?} is not a valid u16: {error}")), + }, + _ => None, + }; + Ok(resolve_bind_addr(host, edgezero_port, None, fallback_port)) } #[cfg(test)] mod tests { + use std::net::SocketAddr; + use super::*; #[test] fn crate_compiles() {} + #[test] + fn bind_address_uses_edgezero_host_and_port_first() { + let resolution = bind_address(Some("0.0.0.0"), Some("8080"), Some("9000")) + .expect("should accept valid listen settings"); + assert_eq!( + resolution.addr, + SocketAddr::from(([0, 0, 0, 0], 8080)), + "should bind every interface on the EdgeZero port" + ); + assert!( + resolution.warnings.is_empty(), + "should not warn on valid values" + ); + let resolution = + bind_address(Some("::"), None, Some("9000")).expect("should accept an IPv6 host"); + assert_eq!( + resolution.addr, + "[::]:9000".parse().expect("should parse IPv6 fixture"), + "should fall back to PORT when no EdgeZero port is set" + ); + } + + #[test] + fn bind_address_keeps_port_and_default_on_loopback() { + let resolution = bind_address(None, None, Some("9000")).expect("should accept PORT"); + assert_eq!( + resolution.addr, + SocketAddr::from(([127, 0, 0, 1], 9000)), + "should keep PORT on the loopback address" + ); + let resolution = bind_address(None, None, None).expect("should accept no settings"); + assert_eq!( + resolution.addr, + SocketAddr::from(([127, 0, 0, 1], 8787)), + "should keep the EdgeZero loopback default" + ); + } + + #[test] + fn bind_address_warns_on_invalid_edgezero_values_and_rejects_invalid_port() { + let resolution = bind_address(Some("localhost"), Some("0"), None) + .expect("should fall back from invalid EdgeZero values"); + assert_eq!( + resolution.addr, + SocketAddr::from(([127, 0, 0, 1], 8787)), + "should fall back to the loopback default" + ); + assert!( + resolution + .warnings + .iter() + .any(|warning| warning.starts_with(HOST_ENV)) + && resolution + .warnings + .iter() + .any(|warning| warning.starts_with(EDGEZERO_PORT_ENV)), + "should warn naming each invalid EdgeZero variable, got: {:?}", + resolution.warnings + ); + let resolution = bind_address(Some("0.0.0.0"), Some("8080"), Some("not-a-port")) + .expect("should ignore PORT when the EdgeZero port is valid"); + assert_eq!( + resolution.addr, + SocketAddr::from(([0, 0, 0, 0], 8080)), + "should not read a platform-injected PORT inside the image" + ); + let message = bind_address(None, None, Some("0")) + .expect_err("should reject PORT=0 instead of binding a random port"); + assert!( + message.starts_with(PORT_ENV), + "should name PORT for the zero port, got: {message}" + ); + let message = bind_address(Some("0.0.0.0"), None, Some("70000")) + .expect_err("should reject an invalid PORT"); + assert!( + message.starts_with(PORT_ENV), + "should name PORT in the error, got: {message}" + ); + } + #[test] fn parse_log_level_defaults_to_info_when_unset_or_blank() { assert_eq!( diff --git a/docs/guide/axum-dev.md b/docs/guide/axum-dev.md index 82c18cd5d..c88a4273c 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -45,6 +45,47 @@ rewrite its origin URL to the Axum listener, and remove the original URL. The exit trap stops the Axum process and stub origin, then removes the temporary config, local-push output, bodies, headers, and logs. +## Listen address + +Axum listens on `127.0.0.1:8787` by default. It reads EdgeZero's +`EDGEZERO__ADAPTER__HOST` and `EDGEZERO__ADAPTER__PORT` first, so a container +can listen on `0.0.0.0`. `PORT` is the port fallback local tooling uses. An +invalid EdgeZero value falls back with a startup warning, as EdgeZero does; an +invalid `PORT` stops the process. The container image sets +`EDGEZERO__ADAPTER__PORT=8080`, so a `PORT` injected by a container platform is +ignored there; set `EDGEZERO__ADAPTER__PORT` instead. + +## Run the container image + +`crates/trusted-server-adapter-axum/Dockerfile` builds the server image from the +repository root. Releases with package publication enabled push it to GHCR as +`trusted-server-axum`; see +[Download from GitHub Packages](./release-verification#download-from-github-packages) +to verify it. The image is Debian slim with CA certificates and the server binary +only. It runs as UID `10001`, writes nothing, listens on `0.0.0.0:8080`, and +reads the same config and secret variables as the smoke above. CI builds it and +runs that smoke against it with a read-only root filesystem: +`AXUM_SMOKE_IMAGE= ./scripts/smoke-axum.sh` (Linux host networking). It +stops on `SIGINT`, the signal the server handles, so `docker stop` shuts it +down without waiting for the kill timeout. SIGTERM draining and readiness are +not implemented yet (#1236). + +```bash +docker build --file crates/trusted-server-adapter-axum/Dockerfile \ + --build-arg TRUSTED_SERVER_VERSION="$(git rev-parse HEAD)" \ + --tag trusted-server-axum . +docker run --rm --read-only --cap-drop ALL --publish 8080:8080 \ + --env TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG \ + --env TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD \ + --env TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET \ + --env TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE \ + trusted-server-axum +``` + +`--env NAME` without a value passes that variable from your shell, so secrets +stay out of the command line. The support status above still applies inside the +container. + ## Logging Axum logs to standard output at `info` by default. Set `RUST_LOG` to a single diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index 7ea6ee1fa..bba1e3abf 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -302,6 +302,78 @@ copy that verified file into a directory on their `PATH` as `ts` and make it executable. Verify before first execution. Individual commands may still require a source checkout and provider tools; see [CLI](./cli.md). +## Download from GitHub Packages + +When a maintainer enables package publication, each release also appears in +the repository's **Packages** list. These packages copy the release; they add no +new trust. Verify the files you pull exactly as you verify release downloads. + +| Package | Tag | Contents | +| ----------------------- | ---------------- | ------------------------------------------------------------------ | +| `trusted-server-cli` | `-` | `ts-`, its SPDX SBOM and both Sigstore bundles | +| `trusted-server-fastly` | `` | the wasm, its `.tar.gz` package, SBOM, both bundles, rebuild check | +| `trusted-server-axum` | `` | Axum server container image for `linux/amd64` (development status) | + +Pulling the CLI and Fastly packages needs ORAS 1.2 or +later; the image needs Docker with Buildx. Neither tool is trusted: the +commands below verify what they fetched. The CLI and Fastly packages are OCI +artifacts, not runnable images. Each file +is one layer, and each layer's digest is that file's SHA-256 from the release's +`SHA256SUMS`. The packages job downloads the published release, authenticates +its `SHA256SUMS`, checks every file against it, then pushes. At publish time it +refuses to replace a tag that holds anything else, but GHCR tags stay mutable: +always verify the files you pulled, or an image by its digest, never a tag +alone. + +```bash +set -euo pipefail +# Take REPO, TAG and COMMIT from your independent source review. +REPO=Example-Org/trusted-server +TAG=v1.2.3 +COMMIT=0123456789abcdef0123456789abcdef01234567 +TARGET=x86_64-unknown-linux-gnu +OWNER=$(printf '%s' "${REPO%%/*}" | tr '[:upper:]' '[:lower:]') +asset="ts-$TARGET" +oras pull "ghcr.io/$OWNER/trusted-server-cli:$TAG-$TARGET" --output pulled +cd pulled +gh attestation verify "$asset" --repo "$REPO" --bundle "$asset.provenance.sigstore.json" \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" --signer-digest "$COMMIT" \ + --deny-self-hosted-runners --predicate-type https://slsa.dev/provenance/v1 \ + --signer-workflow "$REPO/.github/workflows/release-build.yml" +gh release verify-asset "$TAG" "$asset" --repo "$REPO" +``` + +The final command confirms the pulled bytes are the published release asset. To +check the SBOM too, run the per-asset loop from +[Download and verify](#download-and-verify) in the `pulled` directory. `oras +pull` needs no login for a public package. + +The Fastly package carries `trusted-server-fastly.tar.gz`, the archive that +[Deploy the verified Fastly package](#deploy-the-verified-fastly-package) +deploys. The wasm's provenance and SBOM bundles cover it. Pull +`trusted-server-fastly:$TAG` the same way, then complete that section's checks +on the pulled files, including `gh release verify-asset` for the archive, before +deployment. + +The Axum image is an ordinary build of the tagged source, and Axum itself is +still development status. The image is outside the reproducible release cohort: +no rebuild check, SBOM or release asset covers it, only GitHub build provenance +attached to the image digest. Resolve the digest once, verify it, then run that +digest, so a tag moved in between cannot swap the image: + +```bash +image="ghcr.io/$OWNER/trusted-server-axum" +digest=$(docker buildx imagetools inspect "$image:$TAG" --format '{{json .Manifest.Digest}}' | tr -d '"') +[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || exit 1 +gh attestation verify "oci://$image@$digest" --repo "$REPO" \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" --signer-digest "$COMMIT" \ + --deny-self-hosted-runners --predicate-type https://slsa.dev/provenance/v1 \ + --signer-workflow "$REPO/.github/workflows/release.yml" +echo "Run $image@$digest" +``` + +See [Axum development server](./axum-dev#run-the-container-image) for running it. + ## Deploy the verified Fastly package Download `trusted-server-fastly.tar.gz` with the other release assets and complete the provenance, SPDX, release-membership and checksum checks above before deployment. Both build jobs package their own wasm with the tracked `scripts/release/fastly.toml`: exactly `manifest_version = 3` and `name = "trusted-server-fastly"`. It contains no service ID, configuration, build hooks or credentials. The release gate compares the complete normalized tar/gzip bytes as well as the wasm; signing and publication refuse either mismatch. Owner/group, timestamp, paths, member order and permissions are fixed independently of the packing host. @@ -558,6 +630,19 @@ commands below only inspect settings; this workflow applies none of them. release build it gets. A later `main` push cancels an older run that is still waiting in the workflow's concurrency group; re-run a cancelled run before tagging its commit. Phase 1 does not enforce source eligibility. +- **Settings → Secrets and variables → Actions → Variables:** set + `PACKAGE_PUBLICATION_ENABLED` to `true` to mirror releases into GitHub + Packages. First rehearse the exact commit in a separate repository with the + variable set there, and confirm the `packages` and `axum-image` jobs pass on + packages that did not exist before. Without it, the `packages` and `axum-image` jobs are skipped and the + release is unaffected. After the first package publish, check each package's + visibility under the organization's **Packages** settings; make it public + only when the release is public. If the `packages` job fails partway, re-run + it: tags that already hold exactly the release files are kept and the rest + are pushed, and any tag with other content stops the job. If `axum-image` + fails after its push (for example in attestation), delete that package + version under **Packages** before re-running, because the job refuses an + existing tag. - Push the commit to a branch, normally `main`, before pushing its lightweight tag. The build `checks` job refuses a tagged commit that is on no branch, before anything is built or signed: in fork rehearsals, draft creation diff --git a/docs/superpowers/specs/2026-11-11-release-packages-design.md b/docs/superpowers/specs/2026-11-11-release-packages-design.md new file mode 100644 index 000000000..8b5468517 --- /dev/null +++ b/docs/superpowers/specs/2026-11-11-release-packages-design.md @@ -0,0 +1,249 @@ +# Release packages on GHCR + +- **Issues:** part of [#1235](https://github.com/IABTechLab/trusted-server/issues/1235) + (attested releases); implements the image and bind items proposed in + [#1236](https://github.com/IABTechLab/trusted-server/issues/1236) ahead of its + spec +- **Date:** 2026-11-11 +- **Status:** proposed, in review with its implementation PR +- **Stacked on:** #1449 (Phase 2 attested releases) + +## Problem + +A rehearsal release publishes 19 assets, and each run also lists 12 internal +workflow artifacts. Operators could not tell which files to use. There was also +no way to run the Axum server as a container: it only listened on loopback, and +no image existed. + +## Goals + +- Mirror each published release into GHCR as `trusted-server-cli` (tag + `-`) and `trusted-server-fastly` (tag ``). Each package + holds one binary with its SPDX SBOM and Sigstore bundles. The Fastly package + also holds `trusted-server-fastly.tar.gz`, the package operators deploy, which + the wasm's bundles cover, and the rebuild check. Every release file except + `SHA256SUMS` is in exactly one package, and every file is one OCI layer whose + digest is its `SHA256SUMS` entry. +- Publish the Axum server as `trusted-server-axum:`, a container image with + GitHub build provenance, built to the image contract proposed in #1236. +- Link each package to the source repository. +- Keep the release itself unchanged: packages add no trust, and verification + stays the release verification procedure. + +## Non-goals + +- Changing the internal workflow artifacts. +- Making Axum a supported production target. Axum stays development status, and + the image says so. +- The rest of #1236: Edge Cookie KV, client IP and geo, SIGTERM drain and + readiness, key rotation, and the decisions its spec must settle. That spec may + still change the image and bind choices made here. +- A `linux/arm64` image. +- Adding the image to the reproducible release cohort: no rebuild check or SBOM + covers it. + +## Scope note + +#1236 lists image publishing as a non-goal owned by #897 (`ts up`, using the +stackpop/edgezero#399 recipe). Publishing the Axum image here takes on that part +of #897 early, so release tags ship a matching image. If #897 or #1236's spec +chooses another recipe, the `axum-image` job is the one place to change. + +## Approaches + +1. **A publisher job after the release (chosen).** A `packages` job downloads + the published release by tag, authenticates `SHA256SUMS`, checks every file + against it, and pushes with a pinned, checksum-verified `oras`. It reuses the + release's attestations; the files keep their digests. +2. **Push from the signing jobs.** Earlier, but gives signing jobs registry + authority and can publish packages for a release that later fails. +3. **Per-platform archives in the release.** Shortens the asset list, but changes + the attested release format and every verification command. + +## Design + +### Binary packages + +- `PACKAGE_PUBLICATION_ENABLED=true` gates both new jobs, separately from + `RELEASE_PUBLICATION_ENABLED`. Both need the `release` job to succeed. +- The `packages` job has only `contents: read` and `packages: write`. It + sparse-checks out `.tool-versions`, `publish.mjs`, `packages.mjs` and the notes + template. +- `packages.mjs`: + - refuses drafts; + - authenticates `SHA256SUMS` with `gh release verify-asset`; + - requires the download to equal `SHA256SUMS` exactly; + - refuses a release file that no package carries, so a new release asset + stops the job instead of being left out. +- **Existing tags.** + - Only the registry's `: not found` answer means a tag is + absent. Any other + lookup error stops the job. + - A tag already holding exactly the planned files (the same layers and + annotations, an empty config, and no `subject`) is kept. This makes a + re-run finish a partial publish. + - A tag holding anything else stops the job before it pushes. + - Each pushed manifest is checked layer by layer: media type, digest, title, + order and annotations. +- GHCR tags are mutable. The refusal applies only at publish time, so the docs + tell users to verify pulled files, or an image by its digest. + +### Axum image + +- **Build.** It builds from the tagged source with `--locked`. It uses the + pinned Rust image and copies Node from the `.tool-versions` Node image (both + pinned by digest). `RUSTUP_TOOLCHAIN` pins the installed channel, so rustup + downloads nothing. +- **JS bundles.** #1236 proposes a separate Node stage that builds the JS + before Cargo. Here Node is copied into the Rust stage, and the existing tsjs + build script runs `npm ci` and the bundle build. The effect is the same, and + Node does not reach the final image. +- **Runtime.** + - Debian slim with CA certificates, UID `10001`, and an exec-form + entrypoint. Nothing runs a shell at runtime. + - No config, secrets or keys are baked in. Runtime inputs are the documented + `TRUSTED_SERVER_CONFIG_*` and `TRUSTED_SERVER_SECRET_*` environment + variables. + - The process writes nothing and needs no writable mounts, so it runs with a + read-only root filesystem. +- **TLS roots.** Trusted Server's origin fetches use compiled-in webpki roots. + Only EdgeZero's own client reads the system CA store. Switching to native + roots is left to #1236. +- **Shutdown.** `STOPSIGNAL SIGINT`, because EdgeZero v0.0.11 shuts down on + SIGINT only, and PID 1 ignores an unhandled SIGTERM. Drain and readiness wait + for stackpop/edgezero#392 and #1142. +- **Bind address.** Axum now reads EdgeZero's `EDGEZERO__ADAPTER__HOST` and + `EDGEZERO__ADAPTER__PORT` through `edgezero_core::addr::resolve_bind_addr`. + `PORT` is the port fallback, and the default stays `127.0.0.1:8787`. + - `PORT` is read only when no valid EdgeZero port is set. The image sets + `0.0.0.0:8080`, so a platform-injected `PORT`, valid or not, is ignored + there. + - Natively, `PORT` with `EDGEZERO__ADAPTER__HOST` now binds that host. + - `PORT=0` used to bind a random port. It is now rejected, as EdgeZero + rejects port 0; nothing in the repository used it. +- **Embedded version.** The source commit, as for the release binaries. +- **Publishing job.** `axum-image` refuses an existing tag at publish time. It + checks the pushed digest, then attaches build provenance to it with + `actions/attest`. + - The job holds `packages`, `id-token` and `attestations` write while + BuildKit runs the build. BuildKit does not expose those credentials to + build steps. + - Splitting the build from the push is a possible hardening. +- **Recipe drift.** The Dockerfile is not generated from the + stackpop/edgezero#401 recipe. A test keeps its Node and Rust tags equal to + the repository pins, and #1236's spec decides how it tracks that recipe. + +## Test plan + +- **`packages.test.mjs`:** + - mapping and plan; + - the exact `SHA256SUMS` check; + - the manifest check; + - first publish, re-run and partial-publish completion; + - refusal of differing tags; + - failing closed on registry errors; + - drafts; + - Dockerfile pins. +- **`phase1.test.mjs`:** each job is checked on its own: + - the publisher's checkout; + - the `packages` job's sparse checkout, pinned oras and lack of release-write + permission; + - the `axum-image` gate. +- **Axum unit tests:** bind precedence, fallback warnings and invalid `PORT`. +- **`test.yml` `axum-image` job** (every PR and push to `main`): + - builds the image; + - runs `scripts/smoke-axum.sh` against it with a read-only root and no + capabilities, covering the four missing-input cases and the positive + origin request; + - runs one case with the image's own listen defaults on a bridge network; + - checks the image user. +- **A fork rehearsal of a release tag.** + - It publishes all packages and the image. + - Each layer digest matches `SHA256SUMS`, and the packages are pullable + anonymously. + - `gh attestation verify` passes for a pulled CLI binary and for the image + digest. + +## Rollout + +1. **Rehearse.** Before enabling publication, a fork rehearsal of the exact + commit must pass, including a first publish to packages that did not exist. +2. **Merge.** Both jobs stay skipped until a maintainer sets + `PACKAGE_PUBLICATION_ENABLED`. +3. **After the first publish.** A maintainer checks each package's visibility + and repository link under the organization's Packages settings. + +## Failure and recovery + +- **`packages` fails partway.** Re-run the failed job. Matching tags are kept + and the rest are pushed. +- **A tag holds other content.** The job stops before pushing. A maintainer + investigates, deletes the wrong version, then re-runs. +- **`axum-image` fails after pushing** (for example during attestation). Delete + that package version, then re-run. The job refuses an existing tag rather + than attesting an image it cannot prove it built. + +## Review log + +### Round 1 (agent review, 2026-11-11) + +Nothing blocking. These findings were applied: + +- **TLS roots.** The "system CA store" claim was wrong; it is now corrected. +- **Bridge-network smoke case.** Added. It proves the image listens + off-loopback with its own defaults, and the smoke checks the image user. +- **Shutdown.** `STOPSIGNAL SIGINT`, because PID 1 ignored SIGTERM. +- **Existing tags.** "Absent" now means only not-found, and a re-run completes a + partial publish. The "write-once" wording now states that tags are mutable. +- **Recovery.** Steps for partial publishes and failed attestations, in the + spec and in the maintainer checklist. +- **Image verification.** The docs now verify an image by its digest, then run + that digest. +- **Scope.** The overlap with #897 and #1236 is stated, and the image is marked + development status. +- **Spec gaps filled.** Header, test plan, rollout, failure, and the + differences from #1236's proposed contract. +- **Smaller fixes.** + - A test pins the Dockerfile's Node and Rust tags to the repository pins. + - The image embeds the source commit. + - `SHA256SUMS` is authenticated before mirroring. + - The docs say `PORT` is ignored inside the image. + - `RUSTUP_TOOLCHAIN` stops rustup from downloading targets. + +Not changed: + +- Splitting the image build from its push. Recorded above as a possible + hardening. +- GHCR repository linking on the organization. Rollout checks it. + +### Round 2 (agent review, 2026-11-11) + +Nothing blocking. Round 2 confirmed every round 1 change in the code. These +findings were applied: + +- **Stale comment.** The workflow comment still said "write-once"; it is now + corrected. +- **`PORT` precedence.** `PORT` is now read only as the fallback, so a + malformed `PORT` no longer stops the image. `PORT=0` gets an error named for + `PORT`. +- **Not-found detection.** It now matches only `: not found`, in + both the script and the workflow. A test feeds it an unrelated + "credentials not found" message. +- **Exact match on re-run.** A kept tag must also have the empty config and no + `subject`. The `.37` manifest confirms what oras pushes. +- **ORAS prerequisite.** The guide now names ORAS and Docker Buildx. +- **First-publish rehearsal.** Rollout and the maintainer checklist now require + one before enabling publication. In rehearsal `.37`, GHCR answered + `: not found` for packages that did not exist yet, so the + anchored check accepts a first publish. + +Not changed: when a wait times out, the smoke does not print the container log. +The existing native cases behave the same way. + +### Base update (2026-11-12) + +Phase 2 added `trusted-server-fastly.tar.gz` to the release and made it the +documented Fastly deploy path. The Fastly package now carries it, and +`checkDownload` refuses any release file that no package carries. Before this, +the plan omitted the archive and the tests could not notice, because their +fixture release was built from the plan itself. diff --git a/scripts/release/packages.mjs b/scripts/release/packages.mjs new file mode 100644 index 000000000..d27558080 --- /dev/null +++ b/scripts/release/packages.mjs @@ -0,0 +1,275 @@ +// Publishes a published GitHub Release's binaries as GHCR OCI artifacts. +// +// The packages job runs this file from the tagged commit after the release +// job. It downloads the release by tag, checks every file against the +// release's SHA256SUMS, and pushes each binary with its evidence files as one +// artifact. It never builds, signs or changes the release. GHCR tags are +// mutable, so the write-once rule here applies only at publish time. +import * as fs from 'node:fs' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { join } from 'node:path' +import { isReleaseTag, releaseAssets } from './publish.mjs' + +const registry = 'ghcr.io' +const sigstoreBundle = 'application/vnd.dev.sigstore.bundle.v0.3+json' + +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') + +/** + * Returns the GHCR package and tag for one release binary. + */ +export function packageReference(owner, tag, asset) { + const namespace = owner.toLowerCase() + if (asset === 'trusted-server-fastly.wasm') + return `${registry}/${namespace}/trusted-server-fastly:${tag}` + const target = asset.match(/^ts-([a-z0-9_]+(?:-[a-z0-9_]+)+)$/)?.[1] + if (!target) throw Error(`No package for release asset ${asset}`) + return `${registry}/${namespace}/trusted-server-cli:${tag}-${target}` +} + +/** + * Lists the files one binary's artifact carries, with their media types. + * + * The Fastly artifact also carries `trusted-server-fastly.tar.gz`, the + * package operators deploy; the wasm's provenance and SBOM bundles cover it. + */ +export function packageFiles(asset) { + const files = [ + { + name: asset, + mediaType: asset.endsWith('.wasm') + ? 'application/wasm' + : 'application/octet-stream', + }, + ...(asset === 'trusted-server-fastly.wasm' + ? [ + { + name: 'trusted-server-fastly.tar.gz', + mediaType: 'application/gzip', + }, + ] + : []), + { name: `${asset}.spdx.json`, mediaType: 'application/spdx+json' }, + { name: `${asset}.provenance.sigstore.json`, mediaType: sigstoreBundle }, + { name: `${asset}.sbom.sigstore.json`, mediaType: sigstoreBundle }, + ] + if (asset === 'trusted-server-fastly.wasm') + files.push({ + name: `${asset}.rebuild-check.json`, + mediaType: 'application/json', + }) + return files +} + +/** + * Plans one artifact per release binary for `repo` at `tag`. + */ +export function packagePlan(repo, tag, commit) { + if (!isReleaseTag(tag)) throw Error(`Not a release tag: ${tag}`) + if (!/^[0-9a-f]{40}$/.test(commit)) throw Error('Invalid source commit') + const [owner] = repo.split('/') + const source = `https://github.com/${repo}` + return releaseAssets.map((asset) => ({ + asset, + reference: packageReference(owner, tag, asset), + artifactType: asset.endsWith('.wasm') + ? 'application/vnd.trusted-server.fastly.wasm.v1' + : 'application/vnd.trusted-server.cli.v1', + files: packageFiles(asset), + annotations: { + 'org.opencontainers.image.source': source, + 'org.opencontainers.image.revision': commit, + 'org.opencontainers.image.version': tag, + 'org.opencontainers.image.title': asset, + 'org.opencontainers.image.description': `${asset} from Trusted Server ${tag}, with its SPDX SBOM and Sigstore attestation bundles`, + }, + })) +} + +/** + * Checks a downloaded release directory against its SHA256SUMS. + * + * The directory must hold exactly the files SHA256SUMS lists plus SHA256SUMS + * itself, every planned file must be listed, and every listed file must be + * planned, so a new release asset cannot be left out of the packages + * unnoticed. Returns name → sha256. + */ +export function checkDownload(directory, plan) { + const lines = fs + .readFileSync(join(directory, 'SHA256SUMS'), 'utf8') + .split('\n') + .filter(Boolean) + const sums = new Map() + for (const line of lines) { + const match = line.match(/^([0-9a-f]{64}) {2}([A-Za-z0-9._-]+)$/) + if (!match || sums.has(match[2])) + throw Error(`Malformed SHA256SUMS line: ${line}`) + sums.set(match[2], match[1]) + } + const present = fs.readdirSync(directory).sort() + const expected = [...sums.keys(), 'SHA256SUMS'].sort() + if (JSON.stringify(present) !== JSON.stringify(expected)) + throw Error('Downloaded release files differ from SHA256SUMS') + for (const [name, digest] of sums) { + const stat = fs.lstatSync(join(directory, name)) + if (!stat.isFile()) throw Error(`Not a regular file: ${name}`) + if (hash(fs.readFileSync(join(directory, name))) !== digest) + throw Error(`Digest mismatch: ${name}`) + } + const planned = new Set( + plan.flatMap(({ files }) => files.map(({ name }) => name)) + ) + for (const name of planned) + if (!sums.has(name)) throw Error(`Release lacks ${name}`) + for (const name of sums.keys()) + if (!planned.has(name)) throw Error(`No package carries ${name}`) + return sums +} + +/** + * Checks a pushed manifest carries exactly the planned files and digests. + */ +export function checkManifest(manifest, entry, sums) { + const layers = manifest?.layers + if ( + manifest?.artifactType !== entry.artifactType || + manifest.config?.mediaType !== 'application/vnd.oci.empty.v1+json' || + manifest.subject !== undefined || + !Array.isArray(layers) || + layers.length !== entry.files.length + ) + throw Error(`Unexpected manifest for ${entry.reference}`) + entry.files.forEach(({ name, mediaType }, index) => { + const layer = layers[index] + if ( + layer.mediaType !== mediaType || + layer.digest !== `sha256:${sums.get(name)}` || + layer.annotations?.['org.opencontainers.image.title'] !== name + ) + throw Error(`Layer ${name} differs in ${entry.reference}`) + }) + for (const [key, value] of Object.entries(entry.annotations)) + if (manifest.annotations?.[key] !== value) + throw Error(`Annotation ${key} differs in ${entry.reference}`) +} + +/** + * Returns the manifest a reference resolves to, or null when it is absent. + * + * Only the registry's not-found answer means absent. Any other failure, such + * as a network or server error, stops the publish instead of risking an + * overwrite. + */ +function existingManifest(run, reference) { + try { + return JSON.parse( + run('oras', ['manifest', 'fetch', reference], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + ) + } catch (error) { + const stderr = String(error?.stderr ?? '') + // oras reports a registry 404 as `: not found`; any other + // message containing "not found", such as missing credentials, is not + // an answer about this tag. + const escaped = reference.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + if (new RegExp(`${escaped}: not found\\s*$`, 'm').test(stderr)) return null + throw Error(`Cannot check whether ${reference} exists: ${stderr.trim()}`, { + cause: error, + }) + } +} + +/** + * Downloads the published release, checks it and pushes every artifact. + * + * A tag that already holds exactly the planned files is kept, so a re-run + * completes a partial publish. A tag with any other content is refused. + */ +export function publishPackages({ env, run = defaultRun, log = console.log }) { + const repo = env.GH_REPO + const tag = env.RELEASE_TAG + const commit = env.SOURCE_COMMIT + if (env.PACKAGE_PUBLICATION_ENABLED !== 'true') + throw Error('Package publication is not enabled') + const plan = packagePlan(repo, tag, commit) + const release = JSON.parse( + run('gh', [ + 'release', + 'view', + tag, + '--repo', + repo, + '--json', + 'tagName,isDraft', + ]) + ) + if (release.tagName !== tag || release.isDraft !== false) + throw Error(`Release ${tag} is not published`) + const directory = fs.mkdtempSync(join(env.RUNNER_TEMP ?? '/tmp', 'release-')) + run('gh', ['release', 'download', tag, '--repo', repo, '--dir', directory]) + // Authenticate SHA256SUMS against the release attestation, so the digests + // below come from the published release, not only from the download. + run('gh', [ + 'release', + 'verify-asset', + tag, + join(directory, 'SHA256SUMS'), + '--repo', + repo, + ]) + const sums = checkDownload(directory, plan) + const pending = [] + for (const entry of plan) { + const existing = existingManifest(run, entry.reference) + if (existing === null) { + pending.push(entry) + continue + } + try { + checkManifest(existing, entry, sums) + } catch (error) { + throw Error(`Refusing to overwrite ${entry.reference}`, { cause: error }) + } + log('Package already published with the expected files:', entry.reference) + } + for (const entry of pending) { + run( + 'oras', + [ + 'push', + entry.reference, + '--artifact-type', + entry.artifactType, + ...Object.entries(entry.annotations).flatMap(([key, value]) => [ + '--annotation', + `${key}=${value}`, + ]), + ...entry.files.map(({ name, mediaType }) => `${name}:${mediaType}`), + ], + { cwd: directory } + ) + const manifest = JSON.parse( + run('oras', ['manifest', 'fetch', entry.reference]) + ) + checkManifest(manifest, entry, sums) + log('Published package:', entry.reference) + } +} + +function defaultRun(command, args, options = {}) { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + ...options, + }) +} + +if (import.meta.main) + try { + publishPackages({ env: process.env }) + } catch (error) { + console.error(error) + process.exitCode = 1 + } diff --git a/scripts/release/packages.test.mjs b/scripts/release/packages.test.mjs new file mode 100644 index 000000000..7b752e6e5 --- /dev/null +++ b/scripts/release/packages.test.mjs @@ -0,0 +1,409 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { + checkDownload, + checkManifest, + packagePlan, + packageReference, + publishPackages, +} from './packages.mjs' +import { releaseAssets } from './publish.mjs' + +const repo = 'Example-Org/trusted-server' +const tag = 'v1.2.3' +const commit = 'a'.repeat(40) +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') + +/** Writes a complete fictional release and its SHA256SUMS. */ +function releaseDirectory() { + const directory = fs.mkdtempSync(join(tmpdir(), 'packages-')) + const names = packagePlan(repo, tag, commit).flatMap(({ files }) => + files.map(({ name }) => name) + ) + const sums = names + .map((name) => { + const body = `fictional ${name}\n` + fs.writeFileSync(join(directory, name), body) + return `${hash(body)} ${name}\n` + }) + .join('') + fs.writeFileSync(join(directory, 'SHA256SUMS'), sums) + return directory +} + +/** Builds the manifest a correct push of `entry` would store. */ +function manifestFor(entry, sums) { + return { + artifactType: entry.artifactType, + config: { mediaType: 'application/vnd.oci.empty.v1+json' }, + annotations: { + ...entry.annotations, + 'org.opencontainers.image.created': '2026-01-01T00:00:00Z', + }, + layers: entry.files.map(({ name, mediaType }) => ({ + mediaType, + digest: `sha256:${sums.get(name)}`, + annotations: { 'org.opencontainers.image.title': name }, + })), + } +} + +test('maps each release binary to a lowercase GHCR package', () => { + assert.equal( + packageReference('Example-Org', tag, 'trusted-server-fastly.wasm'), + 'ghcr.io/example-org/trusted-server-fastly:v1.2.3', + 'should name the Fastly package without a target suffix' + ) + assert.equal( + packageReference('Example-Org', tag, 'ts-x86_64-unknown-linux-gnu'), + 'ghcr.io/example-org/trusted-server-cli:v1.2.3-x86_64-unknown-linux-gnu', + 'should tag the CLI package by target' + ) + assert.throws( + () => packageReference('Example-Org', tag, 'unexpected.bin'), + /No package/, + 'should refuse an unknown asset' + ) +}) + +test('plans every release binary with its evidence files', () => { + const plan = packagePlan(repo, tag, commit) + assert.deepEqual( + plan.map(({ asset }) => asset), + releaseAssets, + 'should publish exactly the release binaries' + ) + const wasm = plan.find(({ asset }) => asset.endsWith('.wasm')) + assert.deepEqual( + wasm.files.map(({ name }) => name), + [ + 'trusted-server-fastly.wasm', + 'trusted-server-fastly.tar.gz', + 'trusted-server-fastly.wasm.spdx.json', + 'trusted-server-fastly.wasm.provenance.sigstore.json', + 'trusted-server-fastly.wasm.sbom.sigstore.json', + 'trusted-server-fastly.wasm.rebuild-check.json', + ], + 'should carry the deployable package and rebuild check with the Fastly wasm' + ) + assert.equal( + wasm.files.find(({ name }) => name.endsWith('.tar.gz')).mediaType, + 'application/gzip', + 'should type the Fastly package archive as gzip' + ) + assert.equal( + wasm.annotations['org.opencontainers.image.source'], + 'https://github.com/Example-Org/trusted-server', + 'should link the package to its source repository' + ) + assert.throws( + () => packagePlan(repo, 'latest', commit), + /Not a release tag/, + 'should refuse a non-release tag' + ) + assert.throws( + () => packagePlan(repo, tag, 'HEAD'), + /Invalid source commit/, + 'should refuse a symbolic commit' + ) +}) + +test('accepts a release that matches its SHA256SUMS exactly', () => { + const directory = releaseDirectory() + const sums = checkDownload(directory, packagePlan(repo, tag, commit)) + assert.equal(sums.size, 18, 'should list every non-checksum release file') +}) + +test('rejects changed, extra and missing release files', () => { + const plan = packagePlan(repo, tag, commit) + const changed = releaseDirectory() + fs.appendFileSync(join(changed, 'ts-aarch64-apple-darwin'), 'tampered') + assert.throws( + () => checkDownload(changed, plan), + /Digest mismatch: ts-aarch64-apple-darwin/, + 'should reject bytes that differ from SHA256SUMS' + ) + const extra = releaseDirectory() + fs.writeFileSync(join(extra, 'unlisted.txt'), 'extra') + assert.throws( + () => checkDownload(extra, plan), + /differ from SHA256SUMS/, + 'should reject a file SHA256SUMS does not list' + ) + const missing = releaseDirectory() + const sums = fs.readFileSync(join(missing, 'SHA256SUMS'), 'utf8') + fs.writeFileSync( + join(missing, 'SHA256SUMS'), + sums.replace(/^.* ts-x86_64-apple-darwin\.spdx\.json\n/m, '') + ) + fs.rmSync(join(missing, 'ts-x86_64-apple-darwin.spdx.json')) + assert.throws( + () => checkDownload(missing, plan), + /Release lacks ts-x86_64-apple-darwin\.spdx\.json/, + 'should require every planned evidence file' + ) + const unplanned = releaseDirectory() + fs.writeFileSync(join(unplanned, 'new-asset.bin'), 'new') + fs.appendFileSync( + join(unplanned, 'SHA256SUMS'), + `${hash('new')} new-asset.bin\n` + ) + assert.throws( + () => checkDownload(unplanned, plan), + /No package carries new-asset\.bin/, + 'should refuse a release file that no package would publish' + ) +}) + +test('checks the pushed manifest layers, order and annotations', () => { + const directory = releaseDirectory() + const plan = packagePlan(repo, tag, commit) + const sums = checkDownload(directory, plan) + const [entry] = plan + checkManifest(manifestFor(entry, sums), entry, sums) + const swapped = manifestFor(entry, sums) + swapped.layers.reverse() + assert.throws( + () => checkManifest(swapped, entry, sums), + /differs/, + 'should reject reordered layers' + ) + const referring = manifestFor(entry, sums) + referring.subject = { + mediaType: 'application/vnd.oci.image.manifest.v1+json', + } + assert.throws( + () => checkManifest(referring, entry, sums), + /Unexpected manifest/, + 'should reject a manifest that refers to another one' + ) + const unlinked = manifestFor(entry, sums) + delete unlinked.annotations['org.opencontainers.image.source'] + assert.throws( + () => checkManifest(unlinked, entry, sums), + /Annotation org\.opencontainers\.image\.source/, + 'should require the repository link' + ) +}) + +/** Returns an error shaped like a failed `oras` call with this stderr. */ +function registryError(stderr) { + return Object.assign(Error('Command failed'), { stderr }) +} + +/** Builds a fake `gh`/`oras` runner over an in-memory registry. */ +function fakeRunner( + directory, + plan, + sums, + { registry = new Map(), fail } = {} +) { + const calls = [] + const run = (command, args, options = {}) => { + calls.push([command, ...args]) + if (command === 'gh' && args[1] === 'view') + return JSON.stringify({ tagName: tag, isDraft: false }) + if (command === 'gh' && args[1] === 'download') { + const target = args[args.indexOf('--dir') + 1] + for (const name of fs.readdirSync(directory)) + fs.copyFileSync(join(directory, name), join(target, name)) + return '' + } + if (command === 'gh' && args[1] === 'verify-asset') { + assert.match(args[3], /SHA256SUMS$/, 'should authenticate SHA256SUMS') + return '' + } + if (command === 'oras' && args[0] === 'push') { + assert.ok(options.cwd, 'should push from the download') + const entry = plan.find(({ reference }) => reference === args[1]) + registry.set(args[1], manifestFor(entry, sums)) + return '' + } + if (command === 'oras' && args[0] === 'manifest') { + const reference = args.at(-1) + if (fail?.(reference)) + throw registryError('Error: 503 Service Unavailable') + if (!registry.has(reference)) + throw registryError( + `Error response from registry: ${reference}: not found` + ) + return JSON.stringify(registry.get(reference)) + } + throw Error(`unexpected call ${command} ${args.join(' ')}`) + } + return { run, calls, registry } +} + +const enabledEnv = { + GH_REPO: repo, + RELEASE_TAG: tag, + SOURCE_COMMIT: commit, + PACKAGE_PUBLICATION_ENABLED: 'true', + RUNNER_TEMP: tmpdir(), +} + +test('publishes every package once from the authenticated release', () => { + const directory = releaseDirectory() + const plan = packagePlan(repo, tag, commit) + const sums = checkDownload(directory, plan) + const { run, calls, registry } = fakeRunner(directory, plan, sums) + publishPackages({ env: enabledEnv, run, log: () => {} }) + assert.deepEqual( + [...registry.keys()], + plan.map(({ reference }) => reference), + 'should push one artifact per release binary' + ) + const verify = calls.findIndex((call) => call[2] === 'verify-asset') + const push = calls.findIndex((call) => call[1] === 'push') + assert.ok( + verify >= 0 && verify < push, + 'should authenticate SHA256SUMS before pushing' + ) + calls.length = 0 + publishPackages({ env: enabledEnv, run, log: () => {} }) + assert.equal( + calls.filter((call) => call[1] === 'push').length, + 0, + 'should keep matching tags on a re-run instead of pushing again' + ) + assert.throws( + () => + publishPackages({ + env: { ...enabledEnv, PACKAGE_PUBLICATION_ENABLED: 'false' }, + run, + log: () => {}, + }), + /not enabled/, + 'should stay closed until a maintainer enables publication' + ) +}) + +test('completes a partial publish and refuses differing tags', () => { + const directory = releaseDirectory() + const plan = packagePlan(repo, tag, commit) + const sums = checkDownload(directory, plan) + const registry = new Map([[plan[0].reference, manifestFor(plan[0], sums)]]) + const partial = fakeRunner(directory, plan, sums, { registry }) + publishPackages({ env: enabledEnv, run: partial.run, log: () => {} }) + assert.deepEqual( + partial.calls.filter((call) => call[1] === 'push').map((call) => call[2]), + plan.slice(1).map(({ reference }) => reference), + 'should push only the missing packages' + ) + const changed = manifestFor(plan[1], sums) + changed.layers[0].digest = `sha256:${'0'.repeat(64)}` + const differing = fakeRunner(directory, plan, sums, { + registry: new Map([[plan[1].reference, changed]]), + }) + assert.throws( + () => + publishPackages({ env: enabledEnv, run: differing.run, log: () => {} }), + /Refusing to overwrite/, + 'should refuse a tag that holds other content' + ) + assert.equal( + differing.calls.filter((call) => call[1] === 'push').length, + 0, + 'should refuse before pushing anything' + ) +}) + +test('treats only the registry not-found answer for the tag as absent', () => { + const directory = releaseDirectory() + const plan = packagePlan(repo, tag, commit) + const sums = checkDownload(directory, plan) + const { run } = fakeRunner(directory, plan, sums) + const credentials = (command, args, options) => { + if (command === 'oras' && args[0] === 'manifest') + throw registryError('Error: credentials not found in native keychain') + return run(command, args, options) + } + assert.throws( + () => publishPackages({ env: enabledEnv, run: credentials, log: () => {} }), + /Cannot check whether/, + 'should not read an unrelated "not found" as an absent tag' + ) +}) + +test('stops when the registry cannot say whether a tag exists', () => { + const directory = releaseDirectory() + const plan = packagePlan(repo, tag, commit) + const sums = checkDownload(directory, plan) + const flaky = fakeRunner(directory, plan, sums, { + fail: (reference) => reference === plan[2].reference, + }) + assert.throws( + () => publishPackages({ env: enabledEnv, run: flaky.run, log: () => {} }), + /Cannot check whether .* exists: Error: 503/, + 'should not treat a registry error as an absent tag' + ) + assert.equal( + flaky.calls.filter((call) => call[1] === 'push').length, + 0, + 'should push nothing after an unknown existence check' + ) +}) + +test('refuses a draft release', () => { + const run = (command, args) => { + if (command === 'gh' && args[1] === 'view') + return JSON.stringify({ tagName: tag, isDraft: true }) + throw Error('should stop before any other call') + } + assert.throws( + () => + publishPackages({ + env: { + GH_REPO: repo, + RELEASE_TAG: tag, + SOURCE_COMMIT: commit, + PACKAGE_PUBLICATION_ENABLED: 'true', + }, + run, + log: () => {}, + }), + /is not published/, + 'should publish packages only from a published release' + ) +}) + +test('keeps the Axum image on the repository tool pins', () => { + const read = (path) => + fs.readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8') + const dockerfile = read('crates/trusted-server-adapter-axum/Dockerfile') + const node = read('.tool-versions').match(/^nodejs\s+(\S+)$/m)[1] + const rust = read('rust-toolchain.toml').match(/^channel = "([^"]+)"$/m)[1] + assert.match( + dockerfile, + new RegExp( + `library/node:${node.replaceAll('.', '\\.')}-bookworm-slim@sha256:[0-9a-f]{64} ` + ), + 'should build with the .tool-versions Node, pinned by digest' + ) + assert.ok( + dockerfile.includes(`test "$(node --version)" = v${node};`), + 'should assert the copied Node version' + ) + assert.match( + dockerfile, + new RegExp( + `library/rust:${rust.replaceAll('.', '\\.')}-bookworm@sha256:[0-9a-f]{64} ` + ), + 'should build with the rust-toolchain.toml channel, pinned by digest' + ) + assert.ok( + dockerfile.includes(`ENV RUSTUP_TOOLCHAIN=${rust}\n`), + 'should pin rustup to the installed channel' + ) + assert.match( + dockerfile, + /library\/debian:bookworm-slim@sha256:[0-9a-f]{64}\n/, + 'should pin the runtime base by digest' + ) + assert.match(dockerfile, /\nUSER 10001:10001\n/, 'should run as UID 10001') + assert.match(dockerfile, /\nSTOPSIGNAL SIGINT\n/, 'should stop with SIGINT') +}) diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs index efbbb2051..474db6332 100644 --- a/scripts/release/phase1.test.mjs +++ b/scripts/release/phase1.test.mjs @@ -734,7 +734,9 @@ test('workflow signs only tags, separates privileged jobs, and never checks out assert.match(triggers, /tags: \['v\*'\]/) assert.doesNotMatch(workflow, /workflow_dispatch|--clobber/) // The publisher job checks out only its own script, template and tool pins. - const publisher = release.split('\n release:')[1] + const jobs = (name) => + release.split(`\n ${name}:`)[1]?.split(/\n {2}(?=[\w-]+:\n)/)[0] + const publisher = jobs('release') assert.equal((publisher.match(/actions\/checkout@/g) ?? []).length, 1) assert.match(publisher, /persist-credentials: false/) assert.deepEqual( @@ -753,6 +755,35 @@ test('workflow signs only tags, separates privileged jobs, and never checks out assert.match(publisher, /GH_SHA256: [a-f0-9]{64}\n/) assert.match(publisher, /sha256sum --check/) assert.match(publisher, /node publisher\/scripts\/release\/publish\.mjs\n/) + // The package job mirrors only the published release; it never builds. + const packages = jobs('packages') + assert.ok(packages, 'should define the package publication job') + assert.match(packages, /needs: release\n/) + assert.match(packages, /vars\.PACKAGE_PUBLICATION_ENABLED == 'true'/) + assert.equal((packages.match(/actions\/checkout@/g) ?? []).length, 1) + assert.match(packages, /persist-credentials: false/) + assert.deepEqual( + packages + .match(/sparse-checkout: \|\n((?: {12}\S.*\n)+)/)[1] + .trim() + .split(/\s+/), + [ + '.tool-versions', + 'scripts/release/publish.mjs', + 'scripts/release/packages.mjs', + 'scripts/release/release-notes.md', + ] + ) + assert.doesNotMatch(packages, /cargo |npm |phase1-build|contents: write/) + assert.match(packages, /ORAS_SHA256: [a-f0-9]{64}\n/) + assert.match(packages, /node publisher\/scripts\/release\/packages\.mjs\n/) + // The Axum image job may build, but holds no release-writing authority. + const image = jobs('axum-image') + assert.ok(image, 'should define the Axum image job') + assert.match(image, /needs: release\n/) + assert.match(image, /vars\.PACKAGE_PUBLICATION_ENABLED == 'true'/) + assert.doesNotMatch(image, /contents: write|--clobber/) + assert.match(image, /persist-credentials: false/) // Match the repository convention: actions are referenced by major tag. for (const line of [workflow, release] .join('\n') diff --git a/scripts/smoke-axum.sh b/scripts/smoke-axum.sh index ae957e4ba..4ed1bacdf 100755 --- a/scripts/smoke-axum.sh +++ b/scripts/smoke-axum.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash # Exercise the complete Axum local config and secret handoff. +# +# With AXUM_SMOKE_IMAGE set, every case runs that container image instead of +# the native binary: read-only root filesystem, no capabilities, host network +# so the stub origin and per-case ports stay on 127.0.0.1. Config and secrets +# reach the container by variable name, never on its command line. set -euo pipefail REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) @@ -19,20 +24,39 @@ BASE_PORT=${AXUM_SMOKE_PORT:-18890} POSITIVE_PORT=$((BASE_PORT + 4)) ORIGIN_PID="" APP_PID="" +IMAGE=${AXUM_SMOKE_IMAGE:-} +CONTAINER="" -cleanup() { +stop_app() { + if [ -n "$CONTAINER" ]; then + docker rm --force "$CONTAINER" >/dev/null 2>&1 || true + CONTAINER="" + fi smoke_stop_process "$APP_PID" + APP_PID="" +} + +cleanup() { + stop_app smoke_stop_process "$ORIGIN_PID" smoke_remove_workspace "$WORKSPACE" } trap cleanup EXIT INT TERM smoke_resolve_ts_binary "$REPO_ROOT" -AXUM_BINARY=${AXUM_BINARY_PATH:-$REPO_ROOT/target/debug/trusted-server-axum} -if [ ! -x "$AXUM_BINARY" ]; then - cargo build --manifest-path "$REPO_ROOT/Cargo.toml" -p trusted-server-adapter-axum +if [ -n "$IMAGE" ]; then + smoke_require_command docker + IMAGE_USER=$(docker image inspect --format '{{.Config.User}}' "$IMAGE") || + smoke_die "image not found: $IMAGE" + [ "$IMAGE_USER" = "10001:10001" ] || + smoke_die "image must run as 10001:10001, found: ${IMAGE_USER:-root}" +else + AXUM_BINARY=${AXUM_BINARY_PATH:-$REPO_ROOT/target/debug/trusted-server-axum} + if [ ! -x "$AXUM_BINARY" ]; then + cargo build --manifest-path "$REPO_ROOT/Cargo.toml" -p trusted-server-adapter-axum + fi + [ -x "$AXUM_BINARY" ] || smoke_die "Axum binary not executable: $AXUM_BINARY" fi -[ -x "$AXUM_BINARY" ] || smoke_die "Axum binary not executable: $AXUM_BINARY" smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT" ORIGIN_PID=$SMOKE_ORIGIN_PID @@ -82,16 +106,28 @@ run_case() { smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" - "${env_args[@]}" \ - PORT="$port" \ - "$AXUM_BINARY" >"$log_path" 2>&1 & + if [ -n "$IMAGE" ]; then + local name + local docker_env=(--env "EDGEZERO__ADAPTER__PORT=$port") + for name in "$SMOKE_CONFIG_ENV" "$SMOKE_HANDLER_ENV" "$SMOKE_PROXY_ENV" "$SMOKE_EC_ENV"; do + docker_env+=(--env "$name") + done + CONTAINER="trusted-server-axum-smoke-$case_name-$$" + "${env_args[@]}" docker run --rm --init --pull never --name "$CONTAINER" \ + --network host --read-only --cap-drop ALL \ + --security-opt no-new-privileges \ + "${docker_env[@]}" "$IMAGE" >"$log_path" 2>&1 & + else + "${env_args[@]}" \ + PORT="$port" \ + "$AXUM_BINARY" >"$log_path" 2>&1 & + fi APP_PID=$! smoke_wait_http "http://127.0.0.1:$port/" "$expected_status" \ "$body_path" "$headers_path" smoke_assert_process_alive "$APP_PID" "Axum" "$log_path" smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" - smoke_stop_process "$APP_PID" - APP_PID="" + stop_app if [ -n "$expected_diagnostic" ]; then smoke_assert_failure "$expected_status" "$expected_diagnostic" "$log_path" @@ -115,4 +151,23 @@ run_case missing-ec "$((BASE_PORT + 3))" "$ENVELOPE" \ run_case positive "$POSITIVE_PORT" "$ENVELOPE" \ "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 200 -echo "Axum first-success smoke passed" +# The cases above use the host network and override the port. This one keeps +# the image's own listen defaults on a bridge network, so it only answers if +# the server listens outside the container's loopback on port 8080. +if [ -n "$IMAGE" ]; then + DEFAULT_PORT=$((BASE_PORT + 5)) + CONTAINER="trusted-server-axum-smoke-default-$$" + docker run --rm --init --pull never --name "$CONTAINER" \ + --read-only --cap-drop ALL --security-opt no-new-privileges \ + --publish "127.0.0.1:$DEFAULT_PORT:8080" \ + "$IMAGE" >"$WORKSPACE/image-defaults.log" 2>&1 & + APP_PID=$! + smoke_wait_http "http://127.0.0.1:$DEFAULT_PORT/" 500 \ + "$WORKSPACE/image-defaults.body" "$WORKSPACE/image-defaults.headers" + smoke_assert_process_alive "$APP_PID" "Axum image" "$WORKSPACE/image-defaults.log" + stop_app + smoke_assert_failure 500 "env var '$SMOKE_CONFIG_ENV' not set" \ + "$WORKSPACE/image-defaults.log" +fi + +echo "Axum first-success smoke passed${IMAGE:+ in image $IMAGE}"