diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index bae372ef22..bd6b2b75bc 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -183,6 +183,46 @@ jobs: push-to-registry: true create-storage-record: false + - name: Build and push Pulse control-plane image (multi-arch) + id: build_control_plane_image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: deploy/provider-msp/Dockerfile.control-plane + platforms: linux/amd64,linux/arm64 + push: true + provenance: mode=max + sbom: true + cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-control-plane:buildcache + build-args: | + VERSION=${{ steps.version.outputs.tag }} + PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }} + secrets: | + pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} + tags: | + rcourtman/pulse-control-plane:${{ steps.version.outputs.tag }} + rcourtman/pulse-control-plane:${{ steps.version.outputs.version }} + ${{ steps.version.outputs.is_prerelease != 'true' && 'rcourtman/pulse-control-plane:latest' || '' }} + ghcr.io/${{ github.repository_owner }}/pulse-control-plane:${{ steps.version.outputs.tag }} + ghcr.io/${{ github.repository_owner }}/pulse-control-plane:${{ steps.version.outputs.version }} + ${{ steps.version.outputs.is_prerelease != 'true' && format('ghcr.io/{0}/pulse-control-plane:latest', github.repository_owner) || '' }} + + - name: Attest Pulse control-plane image on Docker Hub + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4 + with: + subject-name: docker.io/rcourtman/pulse-control-plane + subject-digest: ${{ steps.build_control_plane_image.outputs.digest }} + push-to-registry: true + create-storage-record: false + + - name: Attest Pulse control-plane image on GHCR + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4 + with: + subject-name: ghcr.io/${{ github.repository_owner }}/pulse-control-plane + subject-digest: ${{ steps.build_control_plane_image.outputs.digest }} + push-to-registry: true + create-storage-record: false + - name: Output image information run: | IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}" @@ -195,6 +235,13 @@ jobs: echo " - rcourtman/pulse:latest" fi echo "" + echo "Control-plane images (linux/amd64, linux/arm64):" + echo " - rcourtman/pulse-control-plane:${{ steps.version.outputs.tag }}" + echo " - rcourtman/pulse-control-plane:${{ steps.version.outputs.version }}" + if [ "$IS_PRERELEASE" != "true" ]; then + echo " - rcourtman/pulse-control-plane:latest" + fi + echo "" echo "Pulse Agent binaries ship as release assets, not as a Docker image." echo "See the GitHub release page for pulse-agent-{darwin,freebsd,linux,windows}-{amd64,arm64,...}." echo "" diff --git a/deploy/provider-msp/Dockerfile.control-plane b/deploy/provider-msp/Dockerfile.control-plane new file mode 100644 index 0000000000..e6c2e6ca28 --- /dev/null +++ b/deploy/provider-msp/Dockerfile.control-plane @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.7 + +FROM --platform=$BUILDPLATFORM golang:1.25.9-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS builder +ARG VERSION=dev +ARG BUILD_TIME=unknown +ARG GIT_COMMIT=unknown +ARG TARGETOS +ARG TARGETARCH +ARG PULSE_LICENSE_PUBLIC_KEY_SHA256 +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,id=pulse-control-plane-go-mod,target=/go/pkg/mod \ + --mount=type=cache,id=pulse-control-plane-go-build,target=/root/.cache/go-build \ + go mod download +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ +COPY pkg/ ./pkg/ +COPY scripts/release_ldflags.sh ./scripts/release_ldflags.sh +RUN --mount=type=cache,id=pulse-control-plane-go-mod,target=/go/pkg/mod \ + --mount=type=cache,id=pulse-control-plane-go-build,target=/root/.cache/go-build \ + --mount=type=secret,id=pulse_license_public_key,required=false \ + set -eu; \ + EXPECTED_LICENSE_PUBLIC_KEY_SHA256="${PULSE_LICENSE_PUBLIC_KEY_SHA256#SHA256:}"; \ + if [ -z "${EXPECTED_LICENSE_PUBLIC_KEY_SHA256}" ]; then echo "Error: PULSE_LICENSE_PUBLIC_KEY_SHA256 is required for control-plane release image builds." >&2; exit 1; fi; \ + if [ ! -f /run/secrets/pulse_license_public_key ]; then echo "Error: PULSE_LICENSE_PUBLIC_KEY_SHA256 was provided but no license public key was mounted." >&2; exit 1; fi; \ + LICENSE_PUBLIC_KEY="$(tr -d '\r\n' < /run/secrets/pulse_license_public_key)"; \ + LICENSE_PUBLIC_KEY_BYTES="$(printf '%s' "${LICENSE_PUBLIC_KEY}" | base64 -d | wc -c | tr -d ' ')"; \ + if [ "${LICENSE_PUBLIC_KEY_BYTES}" != "32" ]; then echo "Error: mounted license public key must decode to 32 bytes." >&2; exit 1; fi; \ + ACTUAL_LICENSE_PUBLIC_KEY_SHA256="$(printf '%s' "${LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')"; \ + if [ "${ACTUAL_LICENSE_PUBLIC_KEY_SHA256}" != "${EXPECTED_LICENSE_PUBLIC_KEY_SHA256}" ]; then echo "Error: mounted license public key does not match PULSE_LICENSE_PUBLIC_KEY_SHA256." >&2; exit 1; fi; \ + TARGET_GOOS="${TARGETOS:-linux}"; \ + TARGET_GOARCH="${TARGETARCH:-$(go env GOARCH)}"; \ + SERVER_LDFLAGS="$(./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}" --license-public-key "${LICENSE_PUBLIC_KEY}")"; \ + CGO_ENABLED=0 GOOS="${TARGET_GOOS}" GOARCH="${TARGET_GOARCH}" go build \ + -tags release \ + -ldflags="${SERVER_LDFLAGS}" \ + -buildvcs=false \ + -trimpath \ + -o /pulse-control-plane ./cmd/pulse-control-plane + +FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc +RUN apk add --no-cache ca-certificates docker-cli +COPY --from=builder /pulse-control-plane /usr/local/bin/pulse-control-plane +ENTRYPOINT ["pulse-control-plane"] diff --git a/deploy/provider-msp/run-install-proof.sh b/deploy/provider-msp/run-install-proof.sh index 999e834571..76ddc8582f 100755 --- a/deploy/provider-msp/run-install-proof.sh +++ b/deploy/provider-msp/run-install-proof.sh @@ -132,7 +132,9 @@ install_args=( if [[ "$skip_runtime_image_pull" == "1" ]]; then install_args+=(--skip-image-pull) fi -install_args+=("${extra_install_args[@]}") +if ((${#extra_install_args[@]} > 0)); then + install_args+=("${extra_install_args[@]}") +fi docker compose run --rm --no-deps control-plane "${install_args[@]}" diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 7acd80048f..8fcc4d8272 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -696,6 +696,19 @@ be reused for release-grade hosted or self-hosted runtime images. The matching installability proof lives in `scripts/installtests/build_release_assets_test.go` and `scripts/release_control/release_promotion_policy_test.py`, and both must assert the secret mount and non-secret fingerprint argument together. +The standalone hosted control-plane image is part of the same release-license +boundary. `deploy/provider-msp/Dockerfile.control-plane` must build +`cmd/pulse-control-plane` with `-tags release`, canonical +`scripts/release_ldflags.sh server` metadata, an embedded license public key +from the BuildKit `pulse_license_public_key` secret, and the same +`PULSE_LICENSE_PUBLIC_KEY_SHA256` fingerprint gate. Provider-hosted MSP uses +that control-plane image for signed MSP-license enforcement, so it must not be +possible to publish a provider MSP control-plane image that accepts +`PULSE_LICENSE_DEV_MODE` or `PULSE_LICENSE_PUBLIC_KEY` runtime overrides. +`.github/workflows/publish-docker.yml` must publish and attest +`rcourtman/pulse-control-plane` and +`ghcr.io//pulse-control-plane` from that Dockerfile, with the same +version tags and prerelease/latest tag policy as the main Pulse runtime image. That same supply-chain boundary also owns the checked-in build roots themselves. `Dockerfile` must pin its Node, Go, and Alpine bases by immutable manifest-list digest so multi-arch release builds do not silently drift onto a diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 17be82c294..d9a6a4ac05 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -563,12 +563,18 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { `sbom: true`, `secrets: |`, `id: license_key_cache`, + `id: build_control_plane_image`, + `file: deploy/provider-msp/Dockerfile.control-plane`, `PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}`, `PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`, `pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}`, `pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}`, `subject-name: docker.io/rcourtman/pulse`, `subject-name: ghcr.io/${{ github.repository_owner }}/pulse`, + `subject-name: docker.io/rcourtman/pulse-control-plane`, + `subject-name: ghcr.io/${{ github.repository_owner }}/pulse-control-plane`, + `rcourtman/pulse-control-plane:${{ steps.version.outputs.tag }}`, + `ghcr.io/${{ github.repository_owner }}/pulse-control-plane:${{ steps.version.outputs.tag }}`, // pulse-agent ships as release-asset binaries, not as a Docker // image (see commit dropping the agent image publish steps). // The agent attestation subject-names intentionally do not diff --git a/scripts/installtests/provider_msp_deploy_test.go b/scripts/installtests/provider_msp_deploy_test.go index 208580b07e..5876e58a8a 100644 --- a/scripts/installtests/provider_msp_deploy_test.go +++ b/scripts/installtests/provider_msp_deploy_test.go @@ -149,6 +149,7 @@ func TestProviderMSPInstallProofRunnerMatchesComposeContract(t *testing.T) { "--install-type", "--target-path", "--skip-image-pull", + "${#extra_install_args[@]}", "docker compose run --rm --no-deps control-plane", "docker compose up -d traefik control-plane", "provider-msp status", @@ -176,6 +177,41 @@ func TestProviderMSPTraefikUsesProviderNetwork(t *testing.T) { ) } +func TestProviderMSPControlPlaneDockerfileBuildsReleaseLicenseBinary(t *testing.T) { + dockerfileBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "Dockerfile.control-plane")) + if err != nil { + t.Fatalf("read control-plane Dockerfile: %v", err) + } + text := string(dockerfileBytes) + assertContainsAll(t, text, + "# syntax=docker/dockerfile:1.7", + "FROM --platform=$BUILDPLATFORM golang:1.25.9-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS builder", + "FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc", + "ARG PULSE_LICENSE_PUBLIC_KEY_SHA256", + "ARG TARGETOS", + "ARG TARGETARCH", + "--mount=type=secret,id=pulse_license_public_key,required=false", + "PULSE_LICENSE_PUBLIC_KEY_SHA256 is required for control-plane release image builds.", + "PULSE_LICENSE_PUBLIC_KEY_SHA256 was provided but no license public key was mounted.", + `LICENSE_PUBLIC_KEY="$(tr -d '\r\n' < /run/secrets/pulse_license_public_key)"`, + "mounted license public key must decode to 32 bytes.", + "mounted license public key does not match PULSE_LICENSE_PUBLIC_KEY_SHA256.", + `TARGET_GOOS="${TARGETOS:-linux}"`, + `TARGET_GOARCH="${TARGETARCH:-$(go env GOARCH)}"`, + `./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}" --license-public-key "${LICENSE_PUBLIC_KEY}"`, + `CGO_ENABLED=0 GOOS="${TARGET_GOOS}" GOARCH="${TARGET_GOARCH}" go build \`, + "-tags release", + "-buildvcs=false", + "-trimpath", + "-o /pulse-control-plane ./cmd/pulse-control-plane", + ) + assertNotContainsAny(t, text, + "golang:1.25.7-alpine AS builder", + "FROM alpine:3.21", + "CGO_ENABLED=0 go build -o /pulse-control-plane ./cmd/pulse-control-plane", + ) +} + func assertContainsAll(t *testing.T, text string, required ...string) { t.Helper() for _, needle := range required {