From 3ffdf785f18312db6aba21778dd29455d9c1e143 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 24 Apr 2026 11:33:20 +0100 Subject: [PATCH] Split hosted runtime image build contract --- Dockerfile | 96 +++++++++++-------- ...iness-runtime-build-contract-2026-04-24.md | 80 ++++++++++++++++ docs/release-control/v6/internal/status.json | 11 +++ .../subsystems/deployment-installability.md | 9 ++ .../installtests/build_release_assets_test.go | 21 +++- 5 files changed, 172 insertions(+), 45 deletions(-) create mode 100644 docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-runtime-build-contract-2026-04-24.md diff --git a/Dockerfile b/Dockerfile index d6acc4018..b09168c2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,6 +84,7 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ -o pulse-linux-arm64 ./cmd/pulse +FROM backend-builder AS release-assets-builder # Build unified agent binaries for all platforms (for download endpoint) RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ @@ -158,7 +159,11 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ if [ -n "${PULSE_UPDATE_SIGNING_PUBLIC_KEY:-}" ] && [ -z "${UPDATE_PUBLIC_KEYS}" ]; then echo "Error: PULSE_UPDATE_SIGNING_PUBLIC_KEY was provided but no update signing key was mounted." >&2; exit 1; fi && \ if [ -n "${PULSE_UPDATE_SIGNING_PUBLIC_KEY:-}" ] && [ "${UPDATE_PUBLIC_KEYS}" != "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" ]; then echo "Error: mounted update signing key does not match PULSE_UPDATE_SIGNING_PUBLIC_KEY." >&2; echo "Expected public key: ${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" >&2; echo "Actual public key: ${UPDATE_PUBLIC_KEYS}" >&2; exit 1; fi && \ if [ -n "${UPDATE_SIGNING_KEY}" ]; then INSTALLER_SSH_PUBLIC_KEY="$(go run ./scripts/release_update_key.go public-key-ssh --private-key "${UPDATE_SIGNING_KEY}" --comment pulse-installer)"; fi && \ - go run ./scripts/render_installers.go --source-dir ./scripts --output-dir /app/rendered-installers --installer-ssh-public-key "${INSTALLER_SSH_PUBLIC_KEY}" && \ + if [ -n "${INSTALLER_SSH_PUBLIC_KEY}" ]; then \ + go run ./scripts/render_installers.go --source-dir ./scripts --output-dir /app/rendered-installers --installer-ssh-public-key "${INSTALLER_SSH_PUBLIC_KEY}"; \ + else \ + go run ./scripts/render_installers.go --source-dir ./scripts --output-dir /app/rendered-installers --installer-ssh-public-key "" --allow-empty-installer-ssh-public-key; \ + fi && \ if [ -n "${UPDATE_SIGNING_KEY}" ]; then \ OPENSSH_SIGNING_KEY=/tmp/pulse-update-signing-key && \ go run ./scripts/release_update_key.go openssh-private-key --private-key "${UPDATE_SIGNING_KEY}" --comment pulse-installer > "${OPENSSH_SIGNING_KEY}" && \ @@ -188,7 +193,7 @@ RUN apk --no-cache add ca-certificates tzdata WORKDIR /app # Copy all unified agent binaries first -COPY --from=backend-builder /app/pulse-agent-linux-* /tmp/ +COPY --from=release-assets-builder /app/pulse-agent-linux-* /tmp/ # Select the appropriate architecture binary # Docker buildx automatically sets TARGETARCH (amd64, arm64, arm) and TARGETVARIANT (v7) @@ -202,14 +207,14 @@ RUN if [ "$TARGETARCH" = "arm64" ]; then \ chmod +x /usr/local/bin/pulse-agent && \ rm -rf /tmp/pulse-agent-* -COPY --from=backend-builder /app/VERSION /VERSION +COPY --from=release-assets-builder /app/VERSION /VERSION ENV PULSE_NO_AUTO_UPDATE=true ENTRYPOINT ["/usr/local/bin/pulse-agent", "--enable-docker", "--enable-host=false"] -# Final stage (Pulse server runtime) -FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS runtime +# Base Pulse server runtime shared by self-hosted and hosted tenant images. +FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS pulse-runtime-base # Use TARGETARCH to select the correct binary for the build platform ARG TARGETARCH @@ -233,16 +238,52 @@ COPY --from=backend-builder /app/VERSION . COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh +# Create config directory +RUN mkdir -p /etc/pulse /data + +# Expose port +EXPOSE 7655 + +# Set environment variables +# Only PULSE_DATA_DIR is used - all node config is done via web UI +ENV PULSE_DATA_DIR=/data +ENV PULSE_DOCKER=true + +# Create default user (will be adjusted by entrypoint if PUID/PGID are set) +RUN adduser -D -u 1000 -g 1000 pulse && \ + chown -R pulse:pulse /app /etc/pulse /data + +# Health check script (handles both HTTP and HTTPS) +COPY docker-healthcheck.sh /docker-healthcheck.sh +RUN chmod +x /docker-healthcheck.sh + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD /docker-healthcheck.sh + +# Use entrypoint script to handle UID/GID +ENTRYPOINT ["/docker-entrypoint.sh"] + +# Run the binary +CMD ["./pulse"] + +# Hosted tenant runtime excludes embedded release installer and agent artifacts. +# Those endpoints can still proxy canonical release assets instead of requiring +# production tenant hotfix builds to carry installer-signing material. +FROM pulse-runtime-base AS hosted_runtime + +# Final stage (Pulse server runtime) +FROM pulse-runtime-base AS runtime + # Provide installer scripts for HTTP download endpoints RUN mkdir -p /opt/pulse/scripts COPY scripts/install-container-agent.sh /opt/pulse/scripts/install-container-agent.sh COPY scripts/install-docker.sh /opt/pulse/scripts/install-docker.sh -COPY --from=backend-builder /app/rendered-installers/install.sh /opt/pulse/scripts/install.sh -COPY --from=backend-builder /app/rendered-installers/install.sh.sig /opt/pulse/scripts/install.sh.sig -COPY --from=backend-builder /app/rendered-installers/install.sh.sshsig /opt/pulse/scripts/install.sh.sshsig -COPY --from=backend-builder /app/rendered-installers/install.ps1 /opt/pulse/scripts/install.ps1 -COPY --from=backend-builder /app/rendered-installers/install.ps1.sig /opt/pulse/scripts/install.ps1.sig -COPY --from=backend-builder /app/rendered-installers/install.ps1.sshsig /opt/pulse/scripts/install.ps1.sshsig +COPY --from=release-assets-builder /app/rendered-installers/install.sh /opt/pulse/scripts/install.sh +COPY --from=release-assets-builder /app/rendered-installers/install.sh.sig /opt/pulse/scripts/install.sh.sig +COPY --from=release-assets-builder /app/rendered-installers/install.sh.sshsig /opt/pulse/scripts/install.sh.sshsig +COPY --from=release-assets-builder /app/rendered-installers/install.ps1 /opt/pulse/scripts/install.ps1 +COPY --from=release-assets-builder /app/rendered-installers/install.ps1.sig /opt/pulse/scripts/install.ps1.sig +COPY --from=release-assets-builder /app/rendered-installers/install.ps1.sshsig /opt/pulse/scripts/install.ps1.sshsig RUN chmod 755 /opt/pulse/scripts/*.sh /opt/pulse/scripts/*.ps1 # Copy all binaries for download endpoint @@ -259,36 +300,9 @@ RUN if [ "$TARGETARCH" = "arm64" ]; then \ # Unified agent binaries (all platforms and architectures) plus detached signatures -COPY --from=backend-builder /app/pulse-agent-* /opt/pulse/bin/ +COPY --from=release-assets-builder /app/pulse-agent-* /opt/pulse/bin/ # Create symlinks for Windows without .exe extension RUN ln -s pulse-agent-windows-amd64.exe /opt/pulse/bin/pulse-agent-windows-amd64 && \ ln -s pulse-agent-windows-arm64.exe /opt/pulse/bin/pulse-agent-windows-arm64 && \ - ln -s pulse-agent-windows-386.exe /opt/pulse/bin/pulse-agent-windows-386 - -# Create config directory -RUN mkdir -p /etc/pulse /data - -# Expose port -EXPOSE 7655 - -# Set environment variables -# Only PULSE_DATA_DIR is used - all node config is done via web UI -ENV PULSE_DATA_DIR=/data -ENV PULSE_DOCKER=true - -# Create default user (will be adjusted by entrypoint if PUID/PGID are set) -RUN adduser -D -u 1000 -g 1000 pulse && \ - chown -R pulse:pulse /app /etc/pulse /data /opt/pulse - -# Health check script (handles both HTTP and HTTPS) -COPY docker-healthcheck.sh /docker-healthcheck.sh -RUN chmod +x /docker-healthcheck.sh - -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD /docker-healthcheck.sh - -# Use entrypoint script to handle UID/GID -ENTRYPOINT ["/docker-entrypoint.sh"] - -# Run the binary -CMD ["./pulse"] + ln -s pulse-agent-windows-386.exe /opt/pulse/bin/pulse-agent-windows-386 && \ + chown -R pulse:pulse /opt/pulse diff --git a/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-runtime-build-contract-2026-04-24.md b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-runtime-build-contract-2026-04-24.md new file mode 100644 index 000000000..e39541757 --- /dev/null +++ b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-runtime-build-contract-2026-04-24.md @@ -0,0 +1,80 @@ +# Cloud Hosted Tier Runtime Build Contract Record + +- Date: `2026-04-24` +- Gate: `cloud-hosted-tier-runtime-readiness` +- Assertion: `RA11` +- Result: `passed` +- Evidence tier: `managed-runtime-exercise` + +## Follow-Up Closed + +The production storage remediation uncovered that ad hoc hosted tenant runtime +image builds were coupled to the release installer signing path. Building the +server `runtime` target from source without release signing material reached +installer rendering and failed with: + +`installer-ssh-public-key is required for rendered installers` + +That was the wrong build boundary for Pulse Cloud tenant hotfix images. Hosted +tenant runtime images need the Pulse server runtime, entrypoint, healthcheck, +and data ownership contract, but they do not need to embed generated installer +scripts, agent binaries, or installer signing material. Those public download +endpoints can continue to proxy canonical release assets when local image +artifacts are absent. + +## Canonical Fix + +`Dockerfile` now splits the build graph into these boundaries: + +1. `backend-builder` builds only the Pulse server binaries and embedded + frontend. +2. `release-assets-builder` derives from `backend-builder` and owns unified + agent binaries, rendered installers, and detached signature sidecars. +3. `pulse-runtime-base` owns the shared Pulse server runtime filesystem, + entrypoint, healthcheck, user, and data directories. +4. `hosted_runtime` derives from `pulse-runtime-base` and intentionally does + not copy rendered installers or embedded agent binaries. +5. `runtime` derives from `pulse-runtime-base` and still copies signed + release installer and agent assets from `release-assets-builder`. +6. `agent_runtime` still depends on `release-assets-builder`. + +Release builds that declare `PULSE_UPDATE_SIGNING_PUBLIC_KEY` still fail closed +unless the matching update signing secret is mounted. Non-release smoke builds +that do not declare that expected public key can render unsigned local installer +placeholders for the full self-hosted `runtime` target, but hosted tenant +hotfix builds can avoid that release-asset path entirely with: + +`DOCKER_BUILDKIT=1 docker build --target hosted_runtime -t pulse-hosted-runtime: .` + +## Proof + +- `go test ./scripts/installtests -run TestDockerAndDemoBuildsUseCanonicalReleaseLdflags -count=1` +- `python3 scripts/release_control/status_audit.py --pretty` +- Docker build proof for the isolated `hosted_runtime` target was run on + `pulse-cloud` from a clean temporary context assembled from `HEAD` plus the + staged Dockerfile patch, so unrelated local working-tree edits did not affect + the result: + - `DOCKER_BUILDKIT=1 docker build --progress=plain --target hosted_runtime -t pulse-hosted-runtime-contract:d5513d479-20260424T100117Z .` + - image inspection passed: `/app/pulse`, `/docker-entrypoint.sh`, and + `/docker-healthcheck.sh` existed; `/opt/pulse/scripts/install.sh` and + `/opt/pulse/bin` did not exist. +- The full self-hosted `runtime` target was also built without signing secrets + from the same clean-context model: + - `DOCKER_BUILDKIT=1 docker build --progress=plain --target runtime -t pulse-runtime-contract:d5513d479-20260424T100740Z .` + - the render-installer step used + `--allow-empty-installer-ssh-public-key` because no expected signing public + key was declared. + - image inspection passed: `/app/pulse`, + `/opt/pulse/scripts/install.sh`, signature sidecars, and + `/opt/pulse/bin/pulse-agent-linux-amd64` existed. +- Both proof images, temporary source contexts, and BuildKit cache were removed + from `pulse-cloud` after verification. The host returned to `13G` used, + `142G` available, `9%` full, with `0B` Docker build cache. + +## Conclusion + +The hosted tenant runtime image build contract is no longer blocked by +installer signing material. The official self-hosted release image keeps the +signed installer/agent asset path, while production Pulse Cloud tenant runtime +hotfix images have a dedicated target that stays scoped to hosted runtime +execution. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index f5d0cda38..986b7ae45 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -920,6 +920,11 @@ "path": "docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-production-remediated-2026-04-24.md", "kind": "file" }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-runtime-build-contract-2026-04-24.md", + "kind": "file" + }, { "repo": "pulse", "path": "docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-storage-blocker-2026-04-23.md", @@ -3495,6 +3500,12 @@ "kind": "file", "evidence_tier": "real-external-e2e" }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-runtime-build-contract-2026-04-24.md", + "kind": "file", + "evidence_tier": "managed-runtime-exercise" + }, { "repo": "pulse", "path": "docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-storage-blocker-2026-04-23.md", diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 44db92ebb..7447ce06f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -347,6 +347,15 @@ jointly stage the canonical shipped docs set into the container build context before `npm run build` runs, rather than relying on a workstation-local checkout layout or leaving hosted runtime image builds unable to resolve `/app/docs/*.md`, `SECURITY.md`, or `TERMS.md`. +That same Docker build graph must keep hosted tenant runtime images separate +from release-installer assembly. `Dockerfile` must expose a `hosted_runtime` +target derived from the shared Pulse server runtime base that copies only the +server runtime assets and does not depend on rendered installers, embedded +agent binaries, or installer signing material. The published self-hosted +`runtime` and `agent_runtime` targets must keep using the release-assets stage +so official release images still carry signed installer and agent download +assets, and any build that declares `PULSE_UPDATE_SIGNING_PUBLIC_KEY` must +continue to fail closed unless the matching signing secret is mounted. That same update-runtime boundary now also owns bounded rollback retention and disk-space fail-closed behavior for self-hosted app updates. `internal/updates/` must prune stale retained rollback snapshots, clear history references when an diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index e34381d31..b598b752a 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -295,8 +295,11 @@ func TestDockerAndDemoBuildsUseCanonicalReleaseLdflags(t *testing.T) { dockerRequired := []string{ `FROM --platform=linux/amd64 node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend-builder`, `FROM --platform=linux/amd64 golang:1.25.9-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS backend-builder`, + `FROM backend-builder AS release-assets-builder`, `FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS agent_runtime`, - `FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS runtime`, + `FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS pulse-runtime-base`, + `FROM pulse-runtime-base AS hosted_runtime`, + `FROM pulse-runtime-base AS runtime`, `COPY scripts/release_ldflags.sh ./scripts/release_ldflags.sh`, `COPY scripts/release_update_key.go ./scripts/release_update_key.go`, `COPY scripts/render_installers.go ./scripts/render_installers.go`, @@ -309,19 +312,29 @@ func TestDockerAndDemoBuildsUseCanonicalReleaseLdflags(t *testing.T) { `./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}"`, `./scripts/release_ldflags.sh agent --version "${VERSION}"`, `go run ./scripts/render_installers.go --source-dir ./scripts --output-dir /app/rendered-installers`, + `--allow-empty-installer-ssh-public-key`, `ssh-keygen -q -Y sign -f "${OPENSSH_SIGNING_KEY}" -n pulse-install`, - `COPY --from=backend-builder /app/rendered-installers/install.sh /opt/pulse/scripts/install.sh`, - `COPY --from=backend-builder /app/pulse-agent-* /opt/pulse/bin/`, + `COPY --from=release-assets-builder /app/rendered-installers/install.sh /opt/pulse/scripts/install.sh`, + `COPY --from=release-assets-builder /app/pulse-agent-* /opt/pulse/bin/`, } for _, needle := range dockerRequired { if !strings.Contains(dockerfile, needle) { t.Fatalf("Dockerfile missing canonical release ldflags usage: %s", needle) } } + hostedStart := strings.Index(dockerfile, `FROM pulse-runtime-base AS hosted_runtime`) + runtimeStart := strings.Index(dockerfile, `FROM pulse-runtime-base AS runtime`) + if hostedStart == -1 || runtimeStart == -1 || hostedStart > runtimeStart { + t.Fatal("Dockerfile must define hosted_runtime from pulse-runtime-base before the full runtime stage") + } + hostedStage := dockerfile[hostedStart:runtimeStart] + if strings.Contains(hostedStage, "rendered-installers") || strings.Contains(hostedStage, "/opt/pulse/bin") { + t.Fatalf("hosted_runtime target must not depend on installer rendering or embedded agent artifacts:\n%s", hostedStage) + } if strings.Contains(dockerfile, `FROM --platform=linux/amd64 node:20-alpine AS frontend-builder`) || strings.Contains(dockerfile, `FROM --platform=linux/amd64 golang:1.25.9-alpine AS backend-builder`) || strings.Contains(dockerfile, `FROM alpine:3.20 AS agent_runtime`) || - strings.Contains(dockerfile, `FROM alpine:3.20 AS runtime`) { + strings.Contains(dockerfile, `FROM alpine:3.20 AS pulse-runtime-base`) { t.Fatal("Dockerfile base images must be pinned by immutable @sha256 digests") }