M1-23: Server image (ADR 0012)¶
Status: ready | Depends on: M1-11 | Milestone: M1
Goal¶
After this ticket the repository builds the image bothy-server for linux/amd64 and
linux/arm64 from services/server/Dockerfile: a static Go binary bothy on a minimal base image
without a shell, running as an unprivileged numeric user, with a read-only root file system and
bothy healthcheck as the container health check. Base images are pinned by digest and a gate
keeps them pinned. make image builds and loads it, make image-test proves its structure, and
the release workflow builds the same file with version and revision. The Compose smoke test
(M1-73) and the release (M7) start from this image.
Read first¶
docs/adr/0012-oci-images-compose-opentofu.md(decision point 1, "Confirmation").docs/spec/18-release.md: "2. Components", "5. Channels" (rule 3: nolatest), "6. Supply chain", "7. Server images".docs/spec/16-operations.md: "2. Building blocks of an instance", "4.2 Two listeners", "5.2 Secret provider", "5.3 Secrets in the Compose stack" (secrets-initruns as0:0), "10. Upgrade".docs/spec/02-architecture.md: "3.3 Commands of the binary" (bothy healthcheck --url,bothy config check), "6. Code layout" (name-bound places: binarybothy, image name).docs/spec/19-security.md: "12. Supply chain, backups and audit integrity" (SEC-SC-4, SEC-SC-5).docs/spec/23-quality.md: "1. Principles" (pinned tools), "8. Supply chain".docs/PLAN.md: section 7, rows M1-11, M1-12, M1-23, M1-73; decisions P-05, P-06, P-09.infra/compose/compose.yaml(anchorsx-hardening,x-server-healthcheck,x-api, servicesecrets-init),.github/workflows/release.yml(jobbuild: context., fileservices/server/Dockerfile, push by digest,make trivy-image),Makefile(targetstrivy,trivy-image,lint),renovate.json(config:best-practiceskeeps digests current),docs/quality/toolchain.md("Trivy: keep the version"),docs/quality/gates.md.- Code merged by earlier tickets:
services/server/cmd/bothy(subcommandsserve,migrate,healthcheck,config),services/server/VERSION,internal/platform/buildinfo(M1-10; 18 "Components" rule 5: the version is a constant held equal toVERSIONby a test). CONTRIBUTING.md.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behaviour change starts with a failing test (red output goes into the report), then code until green, then cleanup.
- No en dash and no em dash in any file you write. White label: no company, person, host or
instance names; the repository placeholder is
OWNER(P-05). - Configuration only through named keys listed in
docs/operations/configuration.md. - 18 "7. Server images": linux/amd64 and linux/arm64 as one multi-arch manifest; not built by
emulation; static Go binary without cgo on a minimal base image without shell, user without root;
HEALTHCHECKand Compose callbothy healthcheck; base images pinned by digest; OCI labelsorg.opencontainers.image.source,.version,.revision,.licenses. - 18 "5. Channels" rule 3: no moving tag
latest. - SEC-SC-4: container base images pinned by digest.
- 16 "1. Goals" rule 5: secrets never in images.
Scope¶
services/server/Dockerfile, build context is the repository root (as inrelease.yml):- Builder stage
FROM --platform=$BUILDPLATFORM golang:<toolchain>-<debian codename>@sha256:<index digest>, where<toolchain>is thetoolchaindirective ofservices/server/go.mod(thegodirective if there is none).ARG TARGETOS TARGETARCH VERSION REVISION. Fail the build ifVERSIONis empty or differs fromservices/server/VERSION. Copygo.modandgo.sumfirst,go mod downloadwith a cache mount, then the sources. Build withCGO_ENABLED=0,GOOS=$TARGETOS,GOARCH=$TARGETARCH,GOTOOLCHAIN=local,GOFLAGS=-mod=readonly,go build -trimpath -buildvcs=false -ldflags "-s -w" -o /out/bothy ./cmd/bothy. The binary embeds its version throughbuildinfo; add no linker flag for it (18 "Components" rule 5). - Final stage
FROM gcr.io/distroless/static-debian<NN>:nonroot@sha256:<index digest>, using the newest Debian line the distroless project lists as supported when you build; record tag and digest in the README.COPY --from=build --chown=0:0 --chmod=0555 /out/bothy /usr/local/bin/bothy.USER 65532:65532.ENTRYPOINT ["/usr/local/bin/bothy"],CMD ["serve"].HEALTHCHECK CMD ["/usr/local/bin/bothy", "healthcheck", "--url", "http://127.0.0.1:8081/readyz"](the default ofserver.internal_listen_addr; same command as Compose). Labels:org.opencontainers.image.title=bothy-server,.sourcefromARG SOURCE_URL(defaulthttps://github.com/OWNER/bothy),.version=$VERSION,.revision=$REVISION,.licenses=Apache-2.0. NoVOLUME, noEXPOSEof host ports, noENVwith secrets, noRUNin the final stage (so no emulation is needed for arm64). - No
# syntax=line: the built-in frontend of current BuildKit supportsRUN --mount. If you need a newer frontend, pin it by digest and extend the pin check of item 4.
- Builder stage
services/server/Dockerfile.dockerignore(Dockerfile-specific ignore file, so the root context ofrelease.ymlstays small and no other build is affected): ignore everything, then allowservices/server/go.mod,go.sum,VERSION,cmd/,internal/,api/,migrations/; ignore again**/*_test.goand**/testdata/. If a package embeds files elsewhere, allow exactly those paths and say so in the report.- Makefile (root), with
##help texts and.PHONYentries:- Variables
IMAGE_NAME ?= bothy-server,IMAGE_TAG ?= $(VERSION of services/server)-local,IMAGE_PLATFORMS ?= linux/amd64,linux/arm64,IMAGE_OCI ?= .cache/image/bothy-server.oci.tar,IMAGE_BUILDER ?=(empty: current buildx builder). image: fails clearly ifdocker buildxis missing; buildsIMAGE_PLATFORMSwith--file services/server/Dockerfile,--build-arg VERSION=...,--build-arg REVISION=$(git rev-parse HEAD)and--output type=oci,dest=$(IMAGE_OCI)from context.; then builds the host platform with--load --tag $(IMAGE_NAME):$(IMAGE_TAG). If the builder cannot build several platforms, the target exits non-zero with the hint to create one (docker buildx create --name <name> --driver docker-container) and to passIMAGE_BUILDER=<name>; it never creates a builder itself.image-test: runsgolangci-lint run --build-tags imagetest ./test/image/...andgo test -tags imagetest -count=1 ./test/image/...inservices/serverwithBOTHY_TEST_IMAGE=$(IMAGE)(default$(IMAGE_NAME):$(IMAGE_TAG)) andBOTHY_TEST_IMAGE_OCI=$(abspath $(IMAGE_OCI)); fails clearly without Docker (P-09).- Neither target joins
gates; both are documented for the release (M7) and for M1-73.
- Variables
- Pin gate
tools/ci/dockerfile_pins.py(standard library only, typed formypy --strict) withtools/ci/tests/test_dockerfile_pins.py, called from thelinttarget: every tracked file matching*Dockerfile*(not*.dockerignore) is read; everyFROMmust reference an image with@sha256:and 64 hex digits, except a reference to an earlier stage name of the same file andscratch;--platform=...flags andAS <name>are understood; a# syntax=line must be pinned the same way. Output names file, line and reference; exit code 1 on a finding. - Image structure test
services/server/test/image/image_test.gowith build tagimagetest, usingos/execfor the Docker CLI and the standard library for tar, JSON and ELF (details under Tests). - Release workflow
.github/workflows/release.yml, step "Build and push the multi-arch server image by digest only": addbuild-argswithVERSION=${{ steps.info.outputs.version }}(the tag as resolved again on the release runner; a value of theresolvejob never reaches an action in arelease*job, rulerelease-untrusted-output, D-W-52),REVISION=${{ github.sha }}andSOURCE_URL=${{ github.server_url }}/${{ github.repository }}, and remove thedocker/setup-qemu-actionstep: the server image is cross compiled and needs no emulation (18 "Server images" rule 2). Nothing else in the workflow changes;make lint(actionlint, zizmor) andmake runners-checkstay green. - Docs and records:
services/server/README.mdsection "Image" (English: build, local tag, user65532, read-only root file system, no shell, health check, base images with tag and digest, how to create a multi-platform builder);docs/spec/18-release.md"7. Server images" already states the numeric user, cross compilation, the digest check and the build arguments; add the build context and the ignore file;docs/quality/gates.mdnamesimage,image-testand the pin check in thelintrow;CONTRIBUTING.mdsays that image work needs Docker with buildx and a multi-platform builder;docs/DECISIONS.mdentry for the base image choice (distroless static instead ofscratch: maintained CA certificates and time zone data, digest updates by Renovate);services/server/CHANGELOG.mdunderUnreleased(Added).
Out of scope¶
bothy-postgresandbothy-backupd(M7), signing, SBOM, provenance and tagging in the release (M7; the workflow already pushes by digest and scans first).- The Compose smoke test with the test overlay (M1-73) and making the installation Compose startable (M7).
- Reproducible image digests across builds: not required here; report whether two builds of the same commit matched if you observe it.
- Changes to
bothy healthcheckorbothy configbehaviour (M1-11, M1-07); if they block the image tests, stop withVerdict: BLOCKEDand name the gap.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-23-server-image, created from the base branch the launcher names. One writer per worktree. - Conventional Commits with the component as scope (for example
build(server): multi-arch image), each commit signed off for the DCO (git commit -s); squash into meaningful commits before the merge. - Never push, tag, open pull requests or create remote repositories; never push an image to any registry.
- Do not modify files outside the scope above, except
docs/DECISIONS.mdandservices/server/CHANGELOG.md. - The core contract (00, 03, 04, 05) is read-only; a gap there ends the ticket with
Verdict: BLOCKED(P-11). - Follow the host rules supplied by the launcher. If no multi-platform buildx builder exists and
the host rules allow it, create one with a unique name for this session and remove it at the
end with
docker buildx rm <name>; if the host rules forbid it, stop withVerdict: BLOCKED. - Containers you start are removed by their ID; images you tag locally are listed in the report.
Stop only processes you started, by PID. Temporary directories only via
mktemp -d(in Go testst.TempDir()), removed at the end. - Never read or print secrets; never open
.envfiles. Test configuration values containEXAMPLEwhere they look like credentials. - No network calls except downloads of pinned tools, Go modules and the pinned base images.
- Do not lower a threshold, disable a gate or delete a test.
Design notes¶
- Cross compilation instead of emulation. The builder stage runs on
$BUILDPLATFORMand compiles for$TARGETOS/$TARGETARCH; the final stage only copies. This meets 18 "7." rule 2 even on one machine, sorelease.ymlloses its QEMU step (Scope 6). - User. Numeric
65532:65532(thenonrootuser of distroless) so that runtimes that check "run as non-root" can verify it without/etc/passwd. The binary belongs to root with mode0555, so the running user cannot replace it.secrets-initin Compose overrides the user with0:0; the image must not prevent that. - Read-only root. The server writes only to mounted volumes and to
/tmp(tmpfs in Composex-hardening); the image declares noVOLUME, so no anonymous volumes appear. - Health check path. Compose and the image call
/usr/local/bin/bothy healthcheck --url http://127.0.0.1:8081/readyz; the binary path is part of the contract withinfra/compose/compose.yaml. - Version.
VERSIONcomes fromservices/server/VERSION(make) or the resolved release version (workflow); the build fails if they differ, so labels and binary never disagree.REVISIONis the commit SHA; a build from a dirty tree still labelsHEAD(documented). - Local tag.
<version>-local, neverlatest. - Trivy.
make trivyscans the committed Dockerfile in config mode (stage it before you run the gate; the target scans tracked files only);make trivy-image IMAGE=<local tag>scans the loaded image with the pinned Trivy0.69.3.
Tests (write them first)¶
tools/ci/tests/test_dockerfile_pins.py: pinnedFROMpasses; tag without digest,latest, digest of the wrong length, unpinned# syntax=line fail; stage reference andscratchpass;--platformandASare parsed; a file withoutFROMpasses; output names file and line. Tools coverage stays at or abovequality.coverage.tools_min_percent.services/server/test/image/image_test.go(//go:build imagetest), image fromBOTHY_TEST_IMAGE, archive fromBOTHY_TEST_IMAGE_OCI:TestImageConfig:docker image inspectgivesUser65532:65532,Entrypoint["/usr/local/bin/bothy"],Cmd["serve"], the health check command of design note 4, the four OCI labels withversionequal toservices/server/VERSIONandlicensesApache-2.0, and no environment variable whose name containsSECRET,PASSWORD,TOKENorKEY.TestImageHasNoShell:docker createplusdocker exportof the file system; the tar hasusr/local/bin/bothy(owner 0, mode0555) and no entry whose base name issh,bash,ash,dash,zshorbusybox;docker run --rm --entrypoint /bin/sh <image> -c truefails.TestBinaryIsStatic: the exportedusr/local/bin/bothyparsed withdebug/elfhas noPT_INTERPprogram header and no imported libraries, and its machine matches the host platform.TestReadOnlyRunAsNonRoot:docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges:true --tmpfs /tmp --network none -e BOTHY_SERVER__PUBLIC_URL=https://chat.example.com <image> config checkexits 0 (add exactly the variablesconfig checkneeds on the base branch; secret refs asenv:with values containingEXAMPLE).TestHealthcheckPresent:docker run --rm --read-only --network none <image> healthcheck --url http://127.0.0.1:8081/readyzexits 1 (nothing listens), which proves the command exists and runs.TestMultiArchArchive: the OCI archive'sindex.jsonleads to manifests for exactlylinux/amd64andlinux/arm64(attestation manifests with platformunknown/unknownare ignored).- Every container the test creates is removed by its ID in
t.Cleanup.
Commands that must pass: make image, make image-test, make trivy,
make trivy-image IMAGE=bothy-server:<version>-local, make lint, make check. Also run
zizmor and actionlint through make lint for the workflow change, and make runners-check.
Acceptance criteria¶
-
docker buildx build --platform linux/amd64,linux/arm64succeeds throughmake image, and the archive holds both platforms. - The container runs as
65532:65532with a read-only root file system and without capabilities; it has no shell; the binary is static. - Every
FROMis pinned by digest, and the pin check inmake lintfails on an unpinnedFROM. - The health check of the image equals the Compose health check.
-
make trivyandmake trivy-image IMAGE=<local image>are green. -
release.ymlpassesVERSION,REVISIONandSOURCE_URL;make lint(actionlint, zizmor) andmake runners-checkare green. - README, 18 "7. Server images",
gates.md,CONTRIBUTING.md,DECISIONS.mdand the CHANGELOG describe the image as built. -
make checkis green.
Definition of done¶
- Code, tests and documentation from the scope are complete: doc comments on every public symbol
of the pin check and the test helpers, README and spec updates,
docs/DECISIONS.mdentries, CHANGELOG entry underUnreleasedinservices/server/CHANGELOG.md. Write an ADR only if you take an architecture decision beyond ADR 0012 and the rulings listed here. make check,make imageandmake image-testare green.- Independent review:
make reviewplus a code review over the diff; then the red team ofdocs/quality/red-team-gate.mdover the diff with verdictJAorNEIN, recorded in the report. OnNEIN: fix with a test per finding and have the delta reviewed again. - Merge locally into the base branch only if review and red team are green and
make checkis green on the merged state; thengit worktree removethe worktree.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, test results with counts (including the red output of the
first test run), the base image tags and digests, the local image tags you created, what is not
proven, and follow-ups that could not be done in scope with the reason. Then write
{{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.