Skip to content

feat: publish openab-sb container image to GHCR on v* tags - #22

Merged
chaodu-agent merged 5 commits into
mainfrom
feat/k8s-container-image
Nov 13, 2026
Merged

chaodu-agent merged 5 commits into
mainfrom
feat/k8s-container-image

Conversation

@chaodu-agent

Copy link
Copy Markdown
Contributor

Adds a container image for openab-sb so OpenAB Connect can deploy it to Kubernetes (design: oablab/oab-pty-mac#117).

Changes

  • `Dockerfile`: debian:bookworm-slim, curl and ca-certificates, numeric non-root user 10001, `/var/lib/openab-sb` owned by that user, `ENTRYPOINT openab-sb serve -c /etc/openab-sb/openab-sb.toml`.
  • `release.yml`: new `image` job, runs only on `v*` tags after `build`, `packages: write` only, pushes `ghcr.io/openabdev/openab-sb` as amd64 and arm64.
  • README: container image section. The exec probe uses `curl -fsS --max-time 2 http://127.0.0.1:8796/healthz\`.

Verification

  • hadolint passes on the Dockerfile.
  • Multi-arch buildx build passes on macmini.
  • release.yml parses as YAML.

Not done

  • Not tagged or released. The image job runs on the next `v*` tag.
  • The staging step's tar extraction was checked against a fake tarball only.

Add a Dockerfile that packages the release's static openab-sb binary on
debian:bookworm-slim with curl and ca-certificates, running as a non-root
user, and an `image` job in release.yml that builds linux/amd64+arm64 and
pushes ghcr.io/openabdev/openab-sb on v* tags. The job reuses the binaries
from the `build` job and has packages:write only.

Verification (see .agents/tasks/k8s-openab-sb/verification.md, outside repo):
- ruby -ryaml load of .github/workflows/release.yml: exit 0, jobs build/publish/image
- staging tar extraction on a fake tarball: exit 0, binary is 0755
- hadolint: not installed, not run
- docker build: docker daemon down, not built
- no cargo/rustc run locally
Address review CHANGES_REQUESTED (findings 1 and 2).

- README: the healthz probe now reads `curl -fsS --max-time 2 ...`, matching
  the settled probe string.
- Dockerfile: use a numeric user, UID/GID 10001, so kubelet runAsNonRoot can
  verify it. Suppress DL3008 inline (pinning bookworm apt versions breaks on
  security updates).
- README: state the UID/GID in the container image section.

Verification (macmini, the only host with docker; this machine has none):
- Input: published v0.2.2 linux-amd64/arm64 tarballs, `shasum -a 256 -c`
  against SHA256SUMS: OK, exit 0.
- hadolint before fix: DL3008 warning, DL3066 info, exit 1. After fix:
  no findings, exit 0.
- `docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile .`:
  exit 0. One harmless useradd warning (UID > SYS_UID_MAX).
- Smoke test of arm64 image: uid=10001, `openab-sb --version` = 0.2.2,
  `check -c` on the example config with generated verifiers: exit 0.
- ruby -ryaml load of .github/workflows/release.yml: exit 0, jobs
  build/publish/image unchanged.
- Not run: GHCR push (tag-only job), amd64 image run, p1.
Record: .agents/tasks/k8s-openab-sb/verification.md (outside repo).
@chaodu-agent

Copy link
Copy Markdown
Contributor Author

Important

CHANGES REQUESTED ⚠️ - The image design is sound, but the documented port does not match the app default, the image can publish without a matching release, and the arm64 binary is never executed before push.

What This PR Does

openab-sb has no container image today, so OpenAB Connect cannot deploy it to Kubernetes. This PR adds a runtime-only Dockerfile and a release job that packages the existing static musl binaries into a multi-arch (amd64, arm64) image on GHCR for every v* tag.

How It Works

  • Dockerfile: debian:bookworm-slim with ca-certificates and curl, numeric non-root user 10001, /var/lib/openab-sb owned by that user, and entrypoint openab-sb serve -c /etc/openab-sb/openab-sb.toml. It does not compile Rust; it copies image-bin/${TARGETARCH}/openab-sb.
  • release.yml: a new image job (needs: build, packages: write) downloads the linux-* artifacts, extracts each binary into image-bin/<arch>/, runs --version on amd64, and pushes with buildx and docker/metadata-action semver tags.
  • README: a new "Container image" section with the pull command, runtime contract, and an exec health probe.

Findings

# Severity Finding Location
1 🟡 Image docs require port 8796, but the code default, example config, and existing tailscale command use 8790 README.md:267, README.md:270, Dockerfile:8
2 🟡 image depends only on build, so an image can be pushed even when the GitHub Release fails .github/workflows/release.yml:108
3 🟡 Only the amd64 binary is executed before the multi-arch push; arm64 is never run .github/workflows/release.yml:131
4 🟡 A new GHCR package is private by default, so the documented anonymous docker pull will fail until visibility is changed README.md:261
5 🟡 No .dockerignore; context: . sends the full checkout, .git, and dist/ tarballs to the builder .github/workflows/release.yml:148
6 🟡 README lists :0.2.2 and :0.2 but not :latest, which metadata-action@v5 also pushes for stable tags README.md:261
7 🟢 Reuses the exact released static binaries, numeric UID for runAsNonRoot, narrowly scoped packages: write -
Finding Details

🟡 F1: Port 8796 vs. 8790

src/config.rs:19 sets DEFAULT_LISTEN = "127.0.0.1:8790", and openab-sb.toml.example:10 and the tailscale line at README.md:222 also use 8790. The Dockerfile comment and the new README section require listen = "127.0.0.1:8796" and probe http://127.0.0.1:8796/healthz. An operator who mounts the example config gets a listener on 8790 and a failing probe.

Requested change: pick one port. Prefer 8790 everywhere, or, if 8796 is required by the Connect deployment design, say so explicitly in the README and explain why it differs from the default.

🟡 F2: Image is not gated on the release

image has needs: build only. If publish (gh release create) fails, GHCR still receives :X.Y.Z and :latest for a version with no GitHub Release or SHA256SUMS.

Requested change: use needs: [build, publish], or document that the two outputs are independent.

🟡 F3: arm64 binary is never executed

The staging step runs image-bin/amd64/openab-sb --version only. A wrong or broken arm64 artifact would be pushed unchecked. The build job does run the binary on its own runner, so the gap is limited to the staging and copy path, but it is cheap to close.

Requested change: after setup-qemu-action, run the arm64 binary too (for example docker run --rm --platform linux/arm64 -v "$PWD/image-bin/arm64:/b" debian:bookworm-slim /b/openab-sb --version), or build with load per platform and run openab-sb --version inside each image before the multi-arch push.

🟡 F4: GHCR package visibility

GitHub docs: a package linked to a repository inherits the repository's access permissions "but not the visibility", and new packages start private. GITHUB_TOKEN cannot change package visibility. The first tagged release will therefore produce a private package, and the README's anonymous docker pull will fail.

Requested change: document the one-time manual step (Package settings -> Change visibility -> Public) in the README or release notes, and optionally add an unauthenticated pull check after the first release.

🟡 F5: Build context hygiene

The repository has no .dockerignore. The Dockerfile only copies image-bin/${TARGETARCH}/openab-sb, so nothing extra reaches the image today, but the whole checkout plus downloaded tarballs is uploaded to the builder, and any future sensitive file in the workspace would be in the context.

Requested change: add a .dockerignore that excludes everything except image-bin/** (* then !image-bin/**), or pass a narrower context.

🟡 F6: :latest is undocumented

docker/metadata-action@v5 defaults to flavor: latest=auto, so v0.2.2 publishes 0.2.2, 0.2, and latest. Prerelease tags such as v0.3.0-rc.1 publish only the full tag.

Requested change: mention :latest in the README, or set flavor: latest=false if the policy is semver-only.

🟢 F7: What is done well

  • The image reuses the tested, static release binaries instead of compiling a second copy.
  • UID and GID 10001 with USER 10001:10001 let kubelet verify runAsNonRoot.
  • The job's permissions are limited to contents: read and packages: write.
  • The staging script uses set -euo pipefail, and the tar member path matches the Package step ($NAME/openab-sb with --strip-components=1 yields image-bin/<arch>/openab-sb; verified by reproducing the archive layout).
Out of scope (pre-existing, not changed by this PR)
  • v* accepts prerelease tags, but the existing publish job calls gh release create without --prerelease, so v0.3.0-rc.1 would become a normal GitHub Release. This predates the PR; consider a follow-up.
Baseline Check
  • PR opened: 2026-11-12
  • Head: 3e07597ec075084f92ded9cc4b509d4ed525cdac; base main; merge-base 8b656fc65a7f0867d372db291ccb8e0e6ee0c1a8; not stacked
  • Diff: 3 files, +98/-0
  • Main already has: static musl release tarballs for linux-amd64/arm64 and darwin-arm64 via release.yml; no Dockerfile or image job
  • Net-new value: a GHCR runtime image and its release job, plus README docs
  • CI: test and poll-and-review pass. The image job only runs on v* tags and has not run yet.
Inline and external feedback

No inline review comments, submitted reviews, or external reviewer comments exist on this PR at the time of this review.

5. Three Reasons We Might Not Need This PR

  1. The tarballs already ship the binary - the image adds deployment convenience, not runtime capability. A Connect deployment could fetch the release tarball in an init container instead.
  2. The image is deployment-specific - the loopback-only listener and tailscale-sidecar assumptions fit one topology. Other operators may prefer to wrap the binary in their own base image.
  3. Debian plus curl adds size and attack surface - a distroless or scratch image would be smaller. The trade-off is that the exec probe would then need an openab-sb subcommand instead of curl.

- F1: use port 8790 everywhere (Dockerfile comment, README), matching
  DEFAULT_LISTEN in src/config.rs. The probe stays
  curl -fsS --max-time 2 http://127.0.0.1:8790/healthz.
- F2: image job now needs [build, publish], so an image is pushed only
  after the GitHub Release succeeds.
- F3: run the arm64 binary under QEMU (docker run --platform linux/arm64)
  before the push. The amd64 --version check is kept.
- F4: release notes and README say a new GHCR package is private by
  default and must be set to Public once before anonymous pull works.
- F5: add .dockerignore (exclude *, re-include image-bin/** and Dockerfile).
- F6: README documents that stable tags also move :latest. The
  metadata-action flavor is unchanged (default latest=auto).

Checks run:
- ruby -ryaml: YAML.load_file(".github/workflows/release.yml") exits 0.
- grep 8796 in README.md, Dockerfile, openab-sb.toml.example,
  release.yml: no matches.
- hadolint not installed locally; not run.
- macmini (OrbStack docker, arm64 host): docker build of the Dockerfile
  with a staged fake image-bin/amd64 binary and this .dockerignore
  succeeded; the COPY resolved. The arm64 step command
  (docker run --rm --platform linux/arm64 -v ...:/tmp/openab-sb:ro
  debian:bookworm-slim /tmp/openab-sb --version) ran and printed output
  with exit 0. This used a fake binary, not the release artifact.
@chaodu-agent

Copy link
Copy Markdown
Contributor Author

Review round 2 at 2743c61

Two independent reviewers, run in parallel on the same head: Opus (claude-opus-5.5) and Sol (gpt-5.6-sol). Both: LGTM, 0 High, 0 Medium. Opus listed 4 Low, Sol listed 2 Low.

Finding (round 1) Fix in 2743c61
F1 port 8796 vs 8790 Dockerfile comment, README and probe use 127.0.0.1:8790, the code default
F2 image not gated on release image job now has needs: [build, publish]
F3 arm64 never executed arm64 binary is run under QEMU before the push, in addition to amd64
F4 GHCR package private by default README and release notes say to set the package to Public once
F5 no .dockerignore .dockerignore excludes everything except image-bin/**
F6 :latest undocumented README documents :latest for stable tags

Extra evidence outside CI: the image was built from this Dockerfile with the real v0.2.2 binaries, started with the config shape OpenAB Connect renders (uid 10001, read-only root, /var/lib/openab-sb volume), check -c passed, the exact exec probe returned 200, /readyz returned 503 with no computer, and it ran 30 s without a restart. A real deploy and rollback on a k3s cluster using this image passed (oablab/oab-pty-mac#118, #117).

Remaining Low items (not blocking, can follow): README pull example names a tag that will not exist until the first v* tag; README could state that a mounted state volume must be writable by UID/GID 10001 (without it openab-sb exits at startup with "opening the audit log"); base image and actions are not pinned to digests or SHAs.

Merging on the two LGTMs. The image job runs on the next v* tag.

@chaodu-agent
chaodu-agent merged commit ce496f7 into main Nov 13, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant