From 00206273d6f225ca826a108ea23e957cd53e8f06 Mon Sep 17 00:00:00 2001 From: Ryan L'Italien Date: Thu, 3 Sep 2026 21:53:37 -0400 Subject: [PATCH 1/2] build: split the image into runtime and uat targets, publish multi-arch Closes #5. The published image was the UAT drill image. Its own header said so, verbatim: "for the UAT drill environment only" and "NOT hardened for production". That is what v0.1.0 pushed to GHCR and what studios pull, so the artifact carrying an explicit not-for-production warning was the one running in production. Two targets off the same Go build stage now: runtime alpine + ca-certificates + the binary default, published by v* tags uat ruby:3.3-alpine, unchanged drill environment only runtime is last, so a bare `docker build` with no --target yields the safe artifact, and release.yml names `target: runtime` explicitly so reordering the file can never quietly publish the drill image again. The Ruby stays where it is load-bearing. fake_p4 is a Ruby script the UAT compose file bind-mounts over /usr/local/bin/p4, and the connector execs whatever binary connector.yml names. The seven-drill harness is unaffected: `make drills` runs `ruby test/drills.rb` on the host (RUBY ?= ruby), never inside an image. alpine rather than scratch or distroless, deliberately. The connector execs perforce.binary (default "p4", internal/tools/perforce.go), so a studio has to be able to supply a real p4 client by bind-mount or in a derived image. scratch forecloses both. Nothing is installed beyond CA certificates and there is no interpreter. Also fixes the arch asymmetry: v0.1.0 shipped an amd64-only image while the release archives carried linux/arm64. setup-qemu-action plus platforms: linux/amd64,linux/arm64. Turned on provenance and SBOM attestations while in the file, which covers one of the #1575 survival conditions. Entrypoint, config path, and uid/gid 10001 are identical between the two targets, so an existing deployment moves by changing only the digest. Verified by building both: runtime 9.6 MiB compressed (21.7 MB on disk) against the published v0.1.0's 40.6 MiB, ruby absent from runtime, `-config` flag intact, and id reporting uid=10001(connector) gid=10001(connector). --- .github/workflows/release.yml | 12 ++++++ Dockerfile | 69 +++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43ee145..f502fc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,10 +79,22 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # QEMU is what lets a single amd64 runner emit the arm64 half of the + # manifest list. Without it buildx silently produces amd64 only, which + # is how v0.1.0 shipped an amd64-only image while the release archives + # carried linux/arm64 (issue #5). + - uses: docker/setup-qemu-action@v3 + # The `runtime` target, explicitly. Not the default-last-stage + # behaviour: naming it here means reordering the Dockerfile can never + # quietly publish the Ruby drill image to studios again. - uses: docker/build-push-action@v7 with: context: . + target: runtime + platforms: linux/amd64,linux/arm64 push: true + provenance: true + sbom: true build-args: VERSION=${{ github.ref_name }} tags: | ghcr.io/butterstack/butterstack-connector:${{ github.ref_name }} diff --git a/Dockerfile b/Dockerfile index 9b13614..ac541bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,29 @@ -# Multi-stage build for the butterstack-connector UAT/drill image -# (connector.spec.js, issue #1574/#1575). +# Multi-stage build with TWO publishable targets. Build with --target. # -# The daemon itself is a static Go binary with no runtime dependencies. The -# final stage is Ruby-based anyway because the UAT "studio LAN" stands the -# real `p4` CLI up with test/support/fake_p4 -- a Ruby script bind-mounted at -# /usr/local/bin/p4 by docker-compose.uat-connector.yml -- and the connector -# execs whatever binary connector.yml names, argv-only, no shell. +# runtime (default, and what `v*` tags publish) alpine + the Go binary +# uat (UAT/drill environment only) adds Ruby for fake_p4 # -# NOT hardened for production (issue #1575 survival conditions 1/4/5: no -# Sigstore keyless signing, no SBOM, no digest-pinned base image, no -# reproducible-build docs -- see connector/README.md "what this does not -# prove"). This image exists for the UAT drill environment only. +# `runtime` is last on purpose, so a bare `docker build` with no --target +# produces the safe artifact rather than the drill one. +# +# Why the split (issue #5): the daemon is a static CGO_ENABLED=0 Go binary +# with no runtime dependencies, but the final stage used to be Ruby-based +# unconditionally, which shipped a 33.9 MiB interpreter layer to every studio +# that pulled a release. Worse, that stage's own header declared it "for the +# UAT drill environment only" and "NOT hardened for production" -- and it was +# exactly what v0.1.0 published and what studios ran. +# +# The Ruby is genuinely needed, just not here: the UAT "studio LAN" stands the +# `p4` CLI up with test/support/fake_p4, a Ruby script bind-mounted at +# /usr/local/bin/p4 by docker-compose.uat-connector.yml, and the connector +# execs whatever binary connector.yml names, argv-only, no shell. So `uat` +# keeps Ruby and `runtime` does not. The seven-drill harness is unaffected +# either way: `make drills` runs `ruby test/drills.rb` on the HOST +# (RUBY ?= ruby in the Makefile), never inside an image. +# +# Still unmet for production, tracked separately (issue #1575 survival +# conditions 1/4/5): no Sigstore keyless signing, no SBOM, no digest-pinned +# base images, no reproducible-build docs. FROM golang:1.25-alpine AS build WORKDIR /src @@ -23,7 +36,10 @@ COPY . . ARG VERSION=dev RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /out/butterstack-connector ./cmd/butterstack-connector -FROM ruby:3.3-alpine +# --- uat ------------------------------------------------------------------- +# Drill/UAT image. Ruby is load-bearing here and only here: fake_p4 is a Ruby +# script the UAT compose file bind-mounts over /usr/local/bin/p4. +FROM ruby:3.3-alpine AS uat RUN apk add --no-cache ca-certificates \ && addgroup -g 10001 connector \ @@ -46,3 +62,32 @@ WORKDIR /home/connector # renders that file from UAT_CONNECTOR_* env vars first (see that script's # header for why that is a UAT-only pattern, not a protocol exception). ENTRYPOINT ["/usr/local/bin/butterstack-connector", "-config", "/etc/butterstack/connector.yml"] + +# --- runtime --------------------------------------------------------------- +# Default target and the one `v*` tags publish. alpine rather than scratch or +# distroless on purpose: the connector execs the binary named by +# connector.yml's `perforce.binary` (default "p4", +# internal/tools/perforce.go's exec.CommandContext), so a studio has to be +# able to supply a real p4 client, by bind-mount or by adding it to a derived +# image. scratch forecloses both. Nothing is installed here beyond CA +# certificates, and the image carries no interpreter. +FROM alpine:3.22 AS runtime + +RUN apk add --no-cache ca-certificates \ + && addgroup -g 10001 connector \ + && adduser -D -u 10001 -G connector -h /home/connector -s /sbin/nologin connector \ + && mkdir -p /etc/butterstack /var/log/connector /tls \ + && chown -R connector:connector /etc/butterstack /var/log/connector /tls /home/connector + +COPY --from=build /out/butterstack-connector /usr/local/bin/butterstack-connector +RUN chmod 0755 /usr/local/bin/butterstack-connector + +# No EXPOSE: the connector never listens on anything. It opens exactly one +# outbound TLS connection and never accepts an inbound one. + +USER connector:connector +WORKDIR /home/connector + +# Identical to the uat stage's entrypoint, config path and uid/gid (10001), +# so an existing deployment moves to this image by changing only the digest. +ENTRYPOINT ["/usr/local/bin/butterstack-connector", "-config", "/etc/butterstack/connector.yml"] From 3c1104f806c60f72182631469cf8027838effe80 Mon Sep 17 00:00:00 2001 From: Ryan L'Italien Date: Thu, 3 Sep 2026 22:05:24 -0400 Subject: [PATCH 2/2] build: runtime image must be glibc, so the studio can mount its own p4 Corrects the runtime base from alpine to distroless/base-debian12. The split itself is unchanged; the base is not a preference, it is forced. The connector execs the binary named by connector.yml's perforce.binary (default "p4", internal/tools/perforce.go). A studio supplies that binary itself, and on bsg-cp-01 it does so by bind-mounting the p4 client out of the Perforce container, so the client version tracks the server automatically. Perforce's packaged p4 is dynamically linked against glibc (ldd: libc.so.6, librt, libdl, libm, libpthread, /lib64/ld-linux-x86-64.so.2), so a musl runtime rejects it outright. Measured against the real binary from the helix-p4d package rather than assumed: alpine:3.22 p4 -V -> "Dynamic loader not found: /lib64/ld-linux-x86-64.so.2" distroless/base-debian12 p4 -V -> runs this runtime image + mount p4 -V -> runs distroless/base rather than debian-slim because it supplies glibc and CA certificates and nothing else: no shell, no package manager, no interpreter. That matters more than usual in an image whose job includes executing an operator-supplied binary. Verified there is no /bin/sh in the result. Pinned by digest, which covers survival condition 4 (#1575). The digest is a manifest index, so linux/amd64 and linux/arm64 both still resolve. uid/gid stay 10001, and the entrypoint and config path are unchanged, so a deployment still moves between targets by changing only the digest. distroless has no shell to run adduser in, so the account and directory skeleton are built in a small alpine stage and copied in; COPY --from preserves numeric ownership. USER is numeric so it does not depend on an /etc/passwd lookup a derived image could overwrite. Size is still most of the win: 15.7 MiB compressed against the published v0.1.0's 40.6 MiB. --- Dockerfile | 63 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index ac541bf..50b47b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Multi-stage build with TWO publishable targets. Build with --target. # -# runtime (default, and what `v*` tags publish) alpine + the Go binary +# runtime (default, and what `v*` tags publish) distroless + the binary # uat (UAT/drill environment only) adds Ruby for fake_p4 # # `runtime` is last on purpose, so a bare `docker build` with no --target @@ -63,31 +63,58 @@ WORKDIR /home/connector # header for why that is a UAT-only pattern, not a protocol exception). ENTRYPOINT ["/usr/local/bin/butterstack-connector", "-config", "/etc/butterstack/connector.yml"] +# --- skel ------------------------------------------------------------------ +# Builds the unprivileged account and the directory skeleton for the runtime +# stage, which has no shell of its own to run adduser/mkdir in. +FROM alpine:3.22 AS skel +RUN addgroup -g 10001 connector \ + && adduser -D -u 10001 -G connector -h /home/connector -s /sbin/nologin connector \ + && mkdir -p /skel/etc/butterstack /skel/var/log/connector /skel/tls /skel/home/connector \ + && chown -R 10001:10001 /skel/etc/butterstack /skel/var/log/connector /skel/tls /skel/home/connector + # --- runtime --------------------------------------------------------------- -# Default target and the one `v*` tags publish. alpine rather than scratch or -# distroless on purpose: the connector execs the binary named by -# connector.yml's `perforce.binary` (default "p4", -# internal/tools/perforce.go's exec.CommandContext), so a studio has to be -# able to supply a real p4 client, by bind-mount or by adding it to a derived -# image. scratch forecloses both. Nothing is installed here beyond CA -# certificates, and the image carries no interpreter. -FROM alpine:3.22 AS runtime +# Default target and the one `v*` tags publish. +# +# glibc, not musl, and that is forced rather than preferred. The connector +# execs the binary named by connector.yml's `perforce.binary` (default "p4", +# internal/tools/perforce.go's exec.CommandContext), and a studio supplies +# that binary itself -- on bsg-cp-01 by bind-mounting the p4 client out of the +# Perforce container so the client version tracks the server automatically. +# Perforce's packaged p4 is DYNAMICALLY linked against glibc (ldd: libc.so.6, +# librt, libdl, libm, libpthread, /lib64/ld-linux-x86-64.so.2), so an +# alpine/musl runtime rejects it outright: +# +# alpine:3.22 -> "Dynamic loader not found: /lib64/ld-linux-x86-64.so.2" +# distroless/base -> `p4 -V` runs +# +# Both measured against the real binary from ButterStack/perforce-debian's +# helix-p4d package, not assumed. +# +# distroless/base rather than debian-slim because it supplies glibc and CA +# certificates and nothing else: no shell, no package manager, no interpreter. +# That matters more than usual here, since this image deliberately executes an +# operator-supplied binary. +# +# Pinned by digest, which is one of the four production survival conditions +# (#1575) the header above lists as unmet. The digest is a manifest INDEX, so +# multi-arch still resolves; bump it deliberately, not by tag drift. +FROM gcr.io/distroless/base-debian12:nonroot@sha256:7f0c72cd138b442ae0deeb69c08b1acf5525439ba251a49ad93c320a061567e5 AS runtime -RUN apk add --no-cache ca-certificates \ - && addgroup -g 10001 connector \ - && adduser -D -u 10001 -G connector -h /home/connector -s /sbin/nologin connector \ - && mkdir -p /etc/butterstack /var/log/connector /tls \ - && chown -R connector:connector /etc/butterstack /var/log/connector /tls /home/connector +# distroless has no shell, so the account and the directories are built in a +# stage that does, then copied in. COPY --from preserves numeric ownership. +# uid/gid stay 10001 to match the uat stage exactly, so moving a deployment +# between the two targets changes only the digest. +COPY --from=skel /etc/passwd /etc/group /etc/ +COPY --from=skel /skel/ / COPY --from=build /out/butterstack-connector /usr/local/bin/butterstack-connector -RUN chmod 0755 /usr/local/bin/butterstack-connector # No EXPOSE: the connector never listens on anything. It opens exactly one # outbound TLS connection and never accepts an inbound one. -USER connector:connector +# Numeric on purpose: independent of /etc/passwd lookup, which a derived image +# could overwrite. +USER 10001:10001 WORKDIR /home/connector -# Identical to the uat stage's entrypoint, config path and uid/gid (10001), -# so an existing deployment moves to this image by changing only the digest. ENTRYPOINT ["/usr/local/bin/butterstack-connector", "-config", "/etc/butterstack/connector.yml"]