mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Split hosted runtime image build contract
This commit is contained in:
+55
-41
@@ -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
|
||||
|
||||
+80
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user