diff --git a/.github/workflows/deployment-enforcement.yml b/.github/workflows/deployment-enforcement.yml new file mode 100644 index 0000000..014eeae --- /dev/null +++ b/.github/workflows/deployment-enforcement.yml @@ -0,0 +1,66 @@ +name: Deployment enforcement + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + inputs: + version: + description: Signed release version to deploy through the enforced path + required: true + default: '0.2.3' + +permissions: + contents: read + packages: read + +env: + VERSION: '0.2.3' + +jobs: + qualify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Select release + if: github.event_name == 'workflow_dispatch' + run: echo "VERSION=${{ inputs.version }}" >> "$GITHUB_ENV" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Install Cosign + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Validate scripts + run: | + bash -n scripts/deploy-verified-compose.sh + bash -n scripts/render-verified-quadlet.sh + - name: Qualify verified Compose deployment + env: + VERSION: ${{ env.VERSION }} + run: | + bash scripts/deploy-verified-compose.sh + image="$(docker inspect glowing-bear --format '{{.Config.Image}}')" + [[ "$image" =~ ^ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$ ]] + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:8080/healthz >/dev/null; then + exit 0 + fi + sleep 1 + done + docker compose logs + exit 1 + - name: Qualify verified Quadlet rendering + env: + VERSION: ${{ env.VERSION }} + run: | + OUTPUT=/tmp/glowing-bear.container bash scripts/render-verified-quadlet.sh + grep -Eq '^Image=ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$' /tmp/glowing-bear.container + ! grep -q '^Image=.*:latest$' /tmp/glowing-bear.container diff --git a/compose.yaml b/compose.yaml index 12b4dfd..6ce0f34 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: glowing-bear: - image: ghcr.io/ploos-as/glowing-bear:latest + image: ${GLOWING_BEAR_IMAGE:-ghcr.io/ploos-as/glowing-bear:latest} build: context: . container_name: glowing-bear diff --git a/docs/M0_12_DEPLOYMENT_ENFORCEMENT.md b/docs/M0_12_DEPLOYMENT_ENFORCEMENT.md new file mode 100644 index 0000000..a1ba4ff --- /dev/null +++ b/docs/M0_12_DEPLOYMENT_ENFORCEMENT.md @@ -0,0 +1,64 @@ +# M0.12 deployment enforcement + +M0.11 added consumer-side Sigstore/Cosign verification. M0.12 turns that verification into the supported secure deployment path for Docker Compose and Podman Quadlet. + +## Policy + +The secure deployment path is fail-closed: + +1. A semantic release version is supplied by the operator. +2. `scripts/verify-release.sh` resolves the release tag to an immutable OCI digest. +3. Cosign verifies that exact digest against the expected GitHub Actions certificate identity and the GitHub Actions OIDC issuer. +4. Only the verified `ghcr.io/ploos-as/glowing-bear@sha256:...` reference is handed to the deployment mechanism. +5. Any resolution, signature, identity, issuer, or immutable-reference failure stops deployment. + +This is an application/operator deployment policy. It is not a Docker daemon, Podman engine, or host-wide admission controller. + +## Docker Compose + +Use: + +```bash +VERSION=0.2.3 bash scripts/deploy-verified-compose.sh +``` + +The script verifies the release, pulls only the verified immutable digest, exports that digest through `GLOWING_BEAR_IMAGE`, validates the Compose model, and starts the service with `--no-build` so a local build cannot replace the verified release image. + +`compose.yaml` still has a `:latest` fallback for development and compatibility. That fallback is not the verified deployment path. + +## Podman Quadlet + +Generate a Quadlet containing the verified immutable digest: + +```bash +VERSION=0.2.3 \ +OUTPUT="$HOME/.config/containers/systemd/glowing-bear.container" \ +bash scripts/render-verified-quadlet.sh + +systemctl --user daemon-reload +systemctl --user restart glowing-bear.service +``` + +The renderer refuses to produce the deployment file unless verification succeeds and the resulting `Image=` value is an immutable `sha256` reference. + +## CI qualification + +`.github/workflows/deployment-enforcement.yml` qualifies M0.12 against the signed `v0.2.3` release. The gate: + +- syntax-checks both enforcement scripts; +- executes the verified Compose deployment path; +- verifies the running container was created from an immutable digest reference; +- requires `/healthz` to become reachable; +- renders a verified Quadlet; +- requires its `Image=` field to contain an immutable digest and rejects `:latest`. + +## Acceptance criteria + +M0.12 is complete when: + +- the verified Compose path deploys only after successful signature verification; +- the Compose runtime image reference is immutable; +- local build substitution is disabled in the verified path; +- the verified Quadlet path emits only an immutable image reference; +- CI proves both paths against a real signed release; +- failures in verification prevent deployment output or startup. diff --git a/scripts/deploy-verified-compose.sh b/scripts/deploy-verified-compose.sh new file mode 100644 index 0000000..b8da85e --- /dev/null +++ b/scripts/deploy-verified-compose.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${VERSION:?VERSION is required}" +COMPOSE_FILE="${COMPOSE_FILE:-compose.yaml}" + +for command in docker cosign; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "required command not found: $command" >&2 + exit 1 + fi +done + +verified_ref="$(VERSION="$VERSION" PULL=0 bash scripts/verify-release.sh)" +if [[ ! "$verified_ref" =~ ^ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$ ]]; then + echo "verification did not return an immutable Glowing Bear image ref" >&2 + exit 1 +fi + +echo "Pulling verified immutable image: $verified_ref" +docker pull "$verified_ref" + +export GLOWING_BEAR_IMAGE="$verified_ref" + +echo "Deploying verified image with Compose: $GLOWING_BEAR_IMAGE" +docker compose -f "$COMPOSE_FILE" config >/dev/null +docker compose -f "$COMPOSE_FILE" up -d --no-build + +echo "Verified Compose deployment started: $GLOWING_BEAR_IMAGE" diff --git a/scripts/qualify_browser_relay.py b/scripts/qualify_browser_relay.py index 5fdd207..0817663 100644 --- a/scripts/qualify_browser_relay.py +++ b/scripts/qualify_browser_relay.py @@ -5,9 +5,21 @@ import asyncio import sys +from playwright.async_api import Error as PlaywrightError from playwright.async_api import async_playwright +async def goto_with_network_retry(page, base_url: str) -> None: + for attempt in range(3): + try: + await page.goto(base_url, wait_until="networkidle", timeout=30_000) + return + except PlaywrightError as exc: + if "ERR_NETWORK_CHANGED" not in str(exc) or attempt == 2: + raise + await asyncio.sleep(1) + + async def qualify( base_url: str, relay_host: str, @@ -26,7 +38,7 @@ async def qualify( page.on("websocket", lambda ws: websocket_urls.append(ws.url)) try: - await page.goto(base_url, wait_until="networkidle", timeout=30_000) + await goto_with_network_retry(page, base_url) await page.locator("#host").fill(relay_host) await page.locator("#port").fill(str(relay_port)) await page.locator("#password").fill(password) diff --git a/scripts/render-verified-quadlet.sh b/scripts/render-verified-quadlet.sh new file mode 100644 index 0000000..5c18435 --- /dev/null +++ b/scripts/render-verified-quadlet.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${VERSION:?VERSION is required}" +OUTPUT="${OUTPUT:-glowing-bear.verified.container}" +TEMPLATE="${TEMPLATE:-quadlet/glowing-bear.container}" + +for command in docker cosign; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "required command not found: $command" >&2 + exit 1 + fi +done + +verified_ref="$(VERSION="$VERSION" PULL=0 bash scripts/verify-release.sh)" +if [[ ! "$verified_ref" =~ ^ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$ ]]; then + echo "verification did not return an immutable Glowing Bear image ref" >&2 + exit 1 +fi + +if [[ ! -f "$TEMPLATE" ]]; then + echo "Quadlet template not found: $TEMPLATE" >&2 + exit 1 +fi + +awk -v image="$verified_ref" ' + /^Image=/ { print "Image=" image; next } + { print } +' "$TEMPLATE" > "$OUTPUT" + +if ! grep -Eq '^Image=ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$' "$OUTPUT"; then + echo "rendered Quadlet does not contain the verified immutable image ref" >&2 + exit 1 +fi + +echo "Rendered verified Quadlet: $OUTPUT" +echo "Immutable image: $verified_ref" diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index e866232..9ff4ebb 100644 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -18,6 +18,11 @@ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then exit 1 fi +if [[ "$PULL" != "0" && "$PULL" != "1" ]]; then + echo "PULL must be 0 or 1" >&2 + exit 1 +fi + for command in docker cosign; do if ! command -v "$command" >/dev/null 2>&1; then echo "required command not found: $command" >&2 @@ -28,7 +33,11 @@ done primary="$IMAGE:$VERSION" identity="https://github.com/Ploos-AS/glowing-bear/.github/workflows/sign-release.yml@refs/tags/$TAG" -image_digest="$(docker buildx imagetools inspect "$primary" | awk '/^Digest:/ {print $2; exit}')" +# Capture the complete Buildx output before parsing it. Piping imagetools inspect +# directly into an awk program that exits after the Digest line can close the +# pipe early; with pipefail enabled Buildx may then report SIGPIPE as exit 255. +inspect_output="$(docker buildx imagetools inspect "$primary")" +image_digest="$(awk '/^Digest:/ {print $2; found=1} END {if (!found) exit 1}' <<<"$inspect_output")" if [[ ! "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "could not resolve an immutable sha256 digest for $primary" >&2 exit 1 @@ -36,24 +45,21 @@ fi signed_ref="$IMAGE@$image_digest" -echo "Resolved release: $primary" -echo "Immutable ref: $signed_ref" -echo "Expected signer: $identity" -echo "Expected issuer: $OIDC_ISSUER" +echo "Resolved release: $primary" >&2 +echo "Immutable ref: $signed_ref" >&2 +echo "Expected signer: $identity" >&2 +echo "Expected issuer: $OIDC_ISSUER" >&2 cosign verify \ --certificate-identity "$identity" \ --certificate-oidc-issuer "$OIDC_ISSUER" \ "$signed_ref" >/dev/null -echo "Signature verification passed for $signed_ref" +echo "Signature verification passed for $signed_ref" >&2 if [[ "$PULL" == "1" ]]; then - docker pull "$signed_ref" - echo "Pulled verified immutable image: $signed_ref" -elif [[ "$PULL" != "0" ]]; then - echo "PULL must be 0 or 1" >&2 - exit 1 + docker pull "$signed_ref" >&2 + echo "Pulled verified immutable image: $signed_ref" >&2 fi printf '%s\n' "$signed_ref"