Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
96 changes: 84 additions & 12 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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) distroless + the 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
Expand All @@ -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 \
Expand All @@ -46,3 +62,59 @@ 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"]

# --- 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.
#
# 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

# 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

# No EXPOSE: the connector never listens on anything. It opens exactly one
# outbound TLS connection and never accepts an inbound one.

# Numeric on purpose: independent of /etc/passwd lookup, which a derived image
# could overwrite.
USER 10001:10001
WORKDIR /home/connector

ENTRYPOINT ["/usr/local/bin/butterstack-connector", "-config", "/etc/butterstack/connector.yml"]