Split hosted runtime image build contract

This commit is contained in:
rcourtman
2026-04-24 11:33:20 +01:00
parent d5513d4797
commit 3ffdf785f1
5 changed files with 172 additions and 45 deletions
+55 -41
View File
@@ -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
@@ -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:<tag> .`
## 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.
@@ -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",
@@ -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
@@ -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")
}