diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84460bde..32a4b108 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,6 +99,21 @@ Do not backport cosmetic changes, new feature additions, or non-security depende - **Version format mismatch.** Product repos dispatch with raw git-describe versions (e.g., `v2026.03.0-473-g072bb6fd1f`). Bakery normalizes these to semver-with-metadata (e.g., `2026.03.0-dev+473-g072bb6fd1f`). If `bakery ci matrix` produces an empty matrix after a dispatch, the formats did not align. The shared workflows strip a leading `v` automatically. Check the rest of the version string against bakery's normalization. +- **Multi-arch builds inside the oven need host-level QEMU setup.** Building + or testing `linux/arm64` targets from an amd64 host (or vice versa) needs + QEMU emulation registered in the host kernel's `binfmt_misc` table. This is + outside the oven image's control — it only relays build instructions to the + host's own Docker daemon over the DooD socket. `binfmt_misc` is global to + the host kernel, not namespaced per-container, so installing it on the host + also covers builds run from inside the oven. On Ubuntu: + + ```bash + sudo apt-get install qemu-user-binfmt + ``` + + `qemu-user-binfmt` integrates with `systemd-binfmt.service`, so + registration survives a reboot without further setup. + ## Change-aware builds `bakery ci matrix` supports `--base-ref ` and `--changed-files-from ` to emit diff --git a/justfile b/justfile index d94423cb..2317af3f 100755 --- a/justfile +++ b/justfile @@ -1,5 +1,14 @@ #!/usr/bin/env just --justfile +# required by the `oven` recipe below: passes *ARGS to the recipe's shell as +# real positional parameters ("$@") instead of `just`'s default plain-text +# substitution. Plain {{ ARGS }} joins arguments with unquoted spaces, so a +# single argument containing a space (e.g. `bash -lc "docker ps"`) silently +# splits into two words before any shell sees it — this passed CI-looking +# (exit 0) but silently wrong output through two separate rounds before it +# was caught. Do not revert this to {{ ARGS }}. +set positional-arguments := true + ################ # Variables CWD := justfile_directory() @@ -26,3 +35,42 @@ _python-executable executable: _setup-pre-commit: pre-commit --version || just _python-executable pre-commit pre-commit install --install-hooks + +################ +# Oven commands (local build/test/validate/scan environment) + +# Build the bakery-oven image +oven-build: + docker build -t bakery-oven -f "{{ CWD }}/oven/Containerfile" "{{ CWD }}/oven" + +# Multi-arch (linux/arm64 from amd64, or vice versa) needs one-time host +# QEMU setup — see CONTRIBUTING.md. + +# Build (if stale) and drop into the oven with sibling repos mounted +# +# images-shared is worked on via git worktrees, so {{ CWD }} is never the +# main checkout — sibling product repos live next to *that*, not next to a +# worktree path, and a worktree's own .git is a pointer file into the main +# checkout's real .git. Resolving the mount root via git-common-dir + two +# dirnames gets the right directory either way (plain checkout or +# worktree). It must be mounted at an identical host/container path because +# dgoss and `bakery build` construct bind-mount arguments that only the +# *host* daemon (on the other end of the socket) ever resolves. +oven *ARGS: oven-build + git_common_dir="$(git -C "{{ CWD }}" rev-parse --path-format=absolute --git-common-dir)"; \ + : "${git_common_dir:?failed to resolve git-common-dir for this checkout}"; \ + mount_root="$(dirname "$(dirname "$git_common_dir")")"; \ + docker_gid="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || stat -f '%g' /var/run/docker.sock)"; \ + : "${docker_gid:?failed to read the group ID of /var/run/docker.sock}"; \ + mkdir -p "$HOME/.cache/bakery-oven/home" || exit 1; \ + docker run --rm -i $(test -t 0 && echo -t) \ + --user "$(id -u):$(id -g)" \ + --group-add "$docker_gid" \ + --network host \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$mount_root:$mount_root" \ + -v "$HOME/.cache/bakery-oven:/opt/oven/state" \ + -e BAKERY_REPO_PATH="{{ CWD }}" \ + -e DGOSS_TEMP_DIR="$mount_root" \ + -w "{{ CWD }}" \ + bakery-oven "$@" diff --git a/oven/Containerfile b/oven/Containerfile new file mode 100644 index 00000000..dff81ff4 --- /dev/null +++ b/oven/Containerfile @@ -0,0 +1,237 @@ +# syntax=docker/dockerfile:1 +# +# "The oven": bundles the tools needed to build, test, validate, and scan a +# Posit product image locally via Docker-outside-of-Docker (DooD) — the +# container never runs its own daemon, it talks to the host's via a mounted +# /var/run/docker.sock. +# +# Runtime contract (enforced by the `oven`/`oven-build` just recipes, not by +# this image — there is no USER instruction): +# --user "$(id -u):$(id -g)" match the host user, so the +# container never writes +# root-owned files into a +# mounted checkout +# --group-add grants socket access +# without needing to know the +# host's docker GID at build +# time +# --network host DooD shares only the daemon's +# *socket*, not its network +# namespace — without this, +# "localhost" here and +# "localhost" on the host-daemon +# side are different network +# stacks, so a container's +# published port (e.g. a local +# test registry) isn't reachable +# via localhost from inside here +# -v /var/run/docker.sock:/var/run/docker.sock DooD +# -v : (identical host/container path) required for any bind +# mount: dgoss/bakery +# generate mount arguments +# the *host* daemon resolves +# against its own filesystem +# -e BAKERY_REPO_PATH= entrypoint syncs +# posit-bakery from here +# -v :/opt/oven/state survives across --rm runs; +# without it every +# invocation re-downloads +# Python, wheels, and the +# trivy DB from scratch +# Invoking this image any other way (e.g. `docker run bakery-oven` with none +# of the above) runs as root and will write root-owned files into whatever +# gets mounted. + +FROM ubuntu:24.04 + +ARG TARGETARCH +ARG GOSS_VERSION=latest +ARG HADOLINT_VERSION=latest +ARG JUST_VERSION=1.58.0 +ARG ORAS_VERSION=1.3.3 +ARG TRIVY_VERSION=0.73.0 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ENV DEBIAN_FRONTEND=noninteractive +# The container runs as an arbitrary host UID (see runtime contract above) +# with no matching /etc/passwd entry, so tools that key off $HOME need an +# explicit value. Pointing it at /opt/oven/state (persistent, see below) +# rather than /tmp means uv's Python/package cache, trivy's vulnerability +# DB, and wizcli's auth token all survive across separate `--rm` runs +# instead of being re-fetched every time. +ENV HOME=/opt/oven/state/home +# The entrypoint writes a `bakery` wrapper here at container start +# (BAKERY_REPO_PATH isn't known until then) — a non-root UID can't write to +# /usr/local/bin, so this needs its own world-writable directory. +ENV PATH="/opt/oven/bin:${PATH}" +# uv must never touch the mounted checkout's own posit-bakery/.venv — that +# file is shared with the host's own direct `uv run` usage, and a +# container-only interpreter path recorded there breaks it (see CLAUDE.md +# for the host workflow). Point uv entirely at paths under /opt/oven/state +# instead: the venv lives on the same filesystem as the cache (avoids uv's +# cross-device "falling back to full copy" warning, lets hardlinks work), +# and both persist across runs via the mount described above. +ENV UV_PROJECT_ENVIRONMENT=/opt/oven/state/venv +ENV UV_CACHE_DIR=/opt/oven/state/cache +ENV UV_PYTHON_INSTALL_DIR=/opt/oven/state/python + +RUN mkdir -p /opt/oven/bin /opt/oven/state \ + && chmod 1777 /opt/oven/bin /opt/oven/state + +# Base utilities +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gnupg \ + git \ + jq \ + && rm -rf /var/lib/apt/lists/* + +# Docker CLI + buildx plugin (CLI only — no daemon/engine package) +RUN install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && . /etc/os-release \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \ + > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + docker-ce-cli \ + docker-buildx-plugin \ + && rm -rf /var/lib/apt/lists/* + +# uv + uvx (Python itself is provisioned on demand by `uv sync`) +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ + +# just (github.com/casey/just) — for parity with the host dev workflow +# (CLAUDE.md's `just test`/`just test-all`/`just setup`), not only the +# `bakery` CLI the entrypoint wraps directly. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) just_arch="x86_64" ;; \ + arm64) just_arch="aarch64" ;; \ + *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + dest="/tmp/just"; mkdir -p "$dest"; cd "$dest"; \ + just_num="${JUST_VERSION#v}"; \ + asset="just-${just_num}-${just_arch}-unknown-linux-musl.tar.gz"; \ + base_url="https://github.com/casey/just/releases/download/${just_num}"; \ + curl -fsSL "${base_url}/${asset}" -o "${asset}"; \ + curl -fsSL "${base_url}/SHA256SUMS" -o SHA256SUMS; \ + sha256sum --ignore-missing -c SHA256SUMS; \ + tar -xzf "${asset}" just; \ + install -m 0755 just /usr/local/bin/just; \ + cd /; rm -rf "$dest" + +# goss + dgoss (github.com/goss-org/goss) — mirrors setup-goss/action.yml's +# current logic: goss's v0.4.10+ releases ship a versioned tarball plus one +# combined SHA256SUMS file, so a "latest" version has to be resolved to a +# concrete tag up front to build the asset filename. dgoss's own release +# filenames are unversioned and unaffected, so it keeps the older bare +# binary + per-file .sha256 scheme. +RUN set -eux; \ + dest="/tmp/goss"; mkdir -p "$dest"; cd "$dest"; \ + case "${TARGETARCH}" in \ + amd64) goss_arch="x86_64" ;; \ + arm64) goss_arch="arm64" ;; \ + *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + if [ "${GOSS_VERSION}" = "latest" ]; then \ + goss_tag=$(curl -fsSL https://api.github.com/repos/goss-org/goss/releases/latest | jq '.tag_name' -r); \ + else \ + goss_tag="v${GOSS_VERSION#v}"; \ + fi; \ + goss_num="${goss_tag#v}"; \ + release_url="https://github.com/goss-org/goss/releases/download/${goss_tag}"; \ + asset="goss_${goss_num}_linux_${goss_arch}.tar.gz"; \ + curl -fsSL "${release_url}/${asset}" -o "${asset}"; \ + curl -fsSL "${release_url}/goss_${goss_num}_SHA256SUMS" -o SHA256SUMS; \ + sha256sum --ignore-missing -c SHA256SUMS; \ + tar -xzf "${asset}" goss; \ + install -m 0755 goss /usr/local/bin/goss; \ + curl -fsSL "${release_url}/dgoss" -o dgoss; \ + curl -fsSL "${release_url}/dgoss.sha256" -o dgoss.sha256; \ + sha256sum -c dgoss.sha256; \ + install -m 0755 dgoss /usr/local/bin/dgoss; \ + cd /; rm -rf "$dest" + +# hadolint (github.com/hadolint/hadolint) — mirrors setup-hadolint/action.yml's +# current logic (v2.15.0+ ships one checksums.sha256 covering every binary; +# older releases ship a per-binary "${binary}.sha256" file instead), plus a +# fix that action doesn't have: GitHub's release-download URLs resolve +# asset names case-insensitively, so probing "hadolint-linux-${arch}" first +# and falling back to "hadolint-Linux-${arch}" on failure never actually +# falls back — the lowercase request succeeds anyway and downloads the +# real (capitalized-named) asset under the wrong local filename. Instead, +# extract the true filename from the checksum file's own content (which is +# authoritative) and use that for both the download and the local name. +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) hadolint_arch="x86_64" ;; \ + arm64) hadolint_arch="arm64" ;; \ + *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + dest="/tmp/hadolint"; mkdir -p "$dest"; cd "$dest"; \ + if [ "${HADOLINT_VERSION}" = "latest" ]; then \ + release_url="https://github.com/hadolint/hadolint/releases/latest/download"; \ + else \ + release_url="https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION#v}"; \ + fi; \ + if curl -fsSL --remove-on-error "${release_url}/checksums.sha256" -o checksums.sha256; then \ + binary=$(awk -v want="hadolint-linux-${hadolint_arch}" '{name=$2; sub(/^\*/,"",name); if (tolower(name)==tolower(want)) {print name; exit}}' checksums.sha256); \ + [ -n "$binary" ] || { echo "no checksums.sha256 entry for hadolint-linux-${hadolint_arch}" >&2; exit 1; }; \ + curl -fsSL --remove-on-error "${release_url}/${binary}" -o "${binary}"; \ + grep -F "${binary}" checksums.sha256 | sha256sum -c -; \ + else \ + curl -fsSL --remove-on-error "${release_url}/hadolint-linux-${hadolint_arch}.sha256" -o guess.sha256; \ + binary=$(awk '{name=$2; sub(/^\*/,"",name); print name}' guess.sha256); \ + mv guess.sha256 "${binary}.sha256"; \ + curl -fsSL --remove-on-error "${release_url}/${binary}" -o "${binary}"; \ + sha256sum -c "${binary}.sha256"; \ + fi; \ + install -m 0755 "${binary}" /usr/local/bin/hadolint; \ + cd /; rm -rf "$dest" + +# wizcli (downloads.wiz.io) — same as setup-wizcli/action.yml; no version pin or checksum available upstream +RUN set -eux; \ + dest="/tmp/wizcli"; mkdir -p "$dest"; cd "$dest"; \ + curl -fsSL "https://downloads.wiz.io/v1/wizcli/latest/wizcli-linux-${TARGETARCH}" -o wizcli; \ + install -m 0755 wizcli /usr/local/bin/wizcli; \ + cd /; rm -rf "$dest" + +# trivy (github.com/aquasecurity/trivy) +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) trivy_arch="64bit" ;; \ + arm64) trivy_arch="ARM64" ;; \ + *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + dest="/tmp/trivy"; mkdir -p "$dest"; cd "$dest"; \ + asset="trivy_${TRIVY_VERSION#v}_Linux-${trivy_arch}.tar.gz"; \ + base_url="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION#v}"; \ + curl -fsSL "${base_url}/${asset}" -o "${asset}"; \ + curl -fsSL "${base_url}/trivy_${TRIVY_VERSION#v}_checksums.txt" -o checksums.txt; \ + sha256sum --ignore-missing -c checksums.txt; \ + tar -xzf "${asset}" trivy; \ + install -m 0755 trivy /usr/local/bin/trivy; \ + cd /; rm -rf "$dest" + +# oras (github.com/oras-project/oras) — `bakery ci merge`'s imagetools +# plugin shells out to it directly. +RUN set -eux; \ + dest="/tmp/oras"; mkdir -p "$dest"; cd "$dest"; \ + oras_num="${ORAS_VERSION#v}"; \ + asset="oras_${oras_num}_linux_${TARGETARCH}.tar.gz"; \ + base_url="https://github.com/oras-project/oras/releases/download/v${oras_num}"; \ + curl -fsSL "${base_url}/${asset}" -o "${asset}"; \ + curl -fsSL "${base_url}/oras_${oras_num}_checksums.txt" -o checksums.txt; \ + sha256sum --ignore-missing -c checksums.txt; \ + tar -xzf "${asset}" oras; \ + install -m 0755 oras /usr/local/bin/oras; \ + cd /; rm -rf "$dest" + +COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["bash"] diff --git a/oven/entrypoint.sh b/oven/entrypoint.sh new file mode 100755 index 00000000..1258aa70 --- /dev/null +++ b/oven/entrypoint.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${BAKERY_REPO_PATH:?BAKERY_REPO_PATH must be set to the images-shared checkout path}" + +uv sync --project "${BAKERY_REPO_PATH}/posit-bakery" + +cat >/opt/oven/bin/bakery <