Publish provider MSP control-plane image

This commit is contained in:
rcourtman 2026-06-02 15:51:21 +01:00
parent 25febfbf82
commit 42fb8eed3f
6 changed files with 149 additions and 1 deletions

View file

@ -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 ""

View file

@ -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"]

View file

@ -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[@]}"

View file

@ -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/<owner>/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

View file

@ -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

View file

@ -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 {