Add matrixed container base image build

Replace the packer-images almalinux base with a Dockerfile matrix over distros/,
publishing one image per distro to docker-internal on a single repo semver.

- bootstrap the internal CA from a sha256-pinned rpm fetched over plain HTTP
- install the estate dnf repo configuration pointed at artifactapi
- run in-image assertions as a --target test build stage
- add pull_request build/test/pre-commit and tag-triggered docker workflows
This commit is contained in:
2026-09-27 17:47:22 +10:00
parent eaeaca20b2
commit 970cfad2ee
13 changed files with 361 additions and 1 deletions
+33
View File
@@ -0,0 +1,33 @@
when:
- event: pull_request
matrix:
include:
# One entry per distro. Adding a distro: add distros/<name>/ and one entry here.
- DISTRO: alma9
IMAGE: almalinux9
BASE_IMAGE: "docker.io/almalinux:9"
steps:
- name: build
image: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest"
settings:
repo: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/${IMAGE}"
target: image
dry_run: true
build_args:
BASE_IMAGE: "${BASE_IMAGE}"
DISTRO: "${DISTRO}"
buildkit_config: |
[registry."artifactapi.k8s.syd1.au.unkin.net"]
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 1Gi
cpu: 1
limits:
memory: 4Gi
cpu: 2
+37
View File
@@ -0,0 +1,37 @@
when:
- event: tag
ref: refs/tags/v*
matrix:
include:
# One entry per distro. Adding a distro: add distros/<name>/ and one entry here.
- DISTRO: alma9
IMAGE: almalinux9
BASE_IMAGE: "docker.io/almalinux:9"
steps:
- name: docker
image: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest"
settings:
registry: artifactapi.k8s.syd1.au.unkin.net
repo: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/${IMAGE}"
target: image
build_args:
BASE_IMAGE: "${BASE_IMAGE}"
DISTRO: "${DISTRO}"
buildkit_config: |
[registry."artifactapi.k8s.syd1.au.unkin.net"]
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
tags:
- "${CI_COMMIT_TAG##v}"
- latest
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 1Gi
cpu: 1
limits:
memory: 4Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: "docker.io/almalinux:9"
commands:
- ./pre-commit.sh
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+35
View File
@@ -0,0 +1,35 @@
when:
- event: pull_request
matrix:
include:
# One entry per distro. Adding a distro: add distros/<name>/ and one entry here.
- DISTRO: alma9
IMAGE: almalinux9
BASE_IMAGE: "docker.io/almalinux:9"
steps:
# The test stage runs test.sh inside the freshly built image, so a failed assertion
# fails the build. No docker daemon required.
- name: test
image: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest"
settings:
repo: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/${IMAGE}"
target: test
dry_run: true
build_args:
BASE_IMAGE: "${BASE_IMAGE}"
DISTRO: "${DISTRO}"
buildkit_config: |
[registry."artifactapi.k8s.syd1.au.unkin.net"]
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 1Gi
cpu: 1
limits:
memory: 4Gi
cpu: 2
+28
View File
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1
ARG BASE_IMAGE=docker.io/almalinux:9
FROM ${BASE_IMAGE} AS image
ARG DISTRO=alma9
COPY distros/${DISTRO}/ /tmp/distro/
RUN set -eux; \
. /tmp/distro/distro.env; \
rm -f /etc/yum.repos.d/*.repo; \
curl -fsSL -o /tmp/ca.rpm "$CA_RPM_URL"; \
echo "$CA_RPM_SHA256 /tmp/ca.rpm" | sha256sum -c -; \
rpm -i /tmp/ca.rpm; \
cp /tmp/distro/yum.repos.d/*.repo /etc/yum.repos.d/; \
dnf makecache; \
dnf update -y; \
dnf install -y $PACKAGES; \
dnf clean all; \
rm -rf /var/cache/dnf /tmp/ca.rpm /tmp/distro
CMD ["/bin/bash"]
# Built only via `--target test`; assertions run as a build step so a broken image
# fails the build, with no docker daemon needed in CI.
FROM image AS test
ARG DISTRO=alma9
COPY distros/${DISTRO}/distro.env test.sh /tmp/test/
RUN cd /tmp/test && ./test.sh
FROM image AS final
+42
View File
@@ -0,0 +1,42 @@
REGISTRY := artifactapi.k8s.syd1.au.unkin.net/docker-internal
DISTROS := $(notdir $(wildcard distros/*))
VERSION ?= $(patsubst v%,%,$(or $(shell git describe --tags --exact-match 2>/dev/null),0.1.0-dev))
.PHONY: build test clean patch minor major
build: $(addprefix build-,$(DISTROS))
test: $(addprefix test-,$(DISTROS))
clean: $(addprefix clean-,$(DISTROS))
# Not .PHONY: GNU make skips implicit-rule search for phony targets.
build-%:
@set -eu; . ./distros/$*/distro.env; \
docker build --build-arg "BASE_IMAGE=$$BASE_IMAGE" --build-arg "DISTRO=$*" \
-t "$(REGISTRY)/$$IMAGE:$(VERSION)" .
test-%:
@set -eu; . ./distros/$*/distro.env; \
docker build --target test --progress plain \
--build-arg "BASE_IMAGE=$$BASE_IMAGE" --build-arg "DISTRO=$*" .
clean-%:
@set -eu; . ./distros/$*/distro.env; \
docker rmi "$(REGISTRY)/$$IMAGE:$(VERSION)" 2>/dev/null || true
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
+33 -1
View File
@@ -1,3 +1,35 @@
# container-base
Base container images for the estate, carrying internal CA trust and dnf/repo configuration, built as a matrix across the supported distros.
The estate's base container images, one per distro, replacing what `packer-images`
built. Published as `artifactapi.k8s.syd1.au.unkin.net/docker-internal/<image>:<version>`
— the image name is the distro (`almalinux9`), the repo's single semver is the tag.
| distro | image | from |
| --- | --- | --- |
| `alma9` | `almalinux9` | `docker.io/almalinux:9` |
Each image bootstraps the internal CA, installs the estate dnf repository
configuration pointed at artifactapi, and installs the base package set.
## Adding a distro
1. `distros/<name>/distro.env` — `BASE_IMAGE`, `IMAGE`, `CA_RPM_URL`, `CA_RPM_SHA256`, `PACKAGES`.
2. `distros/<name>/yum.repos.d/*.repo` — repository configuration for that distro.
3. One `include` entry in each of `.woodpecker/{build,test,docker}.yaml`.
`pre-commit.sh` fails if step 3 is forgotten.
## CA bootstrap
`unkin-ca-certificates` lives in artifactapi, but the image cannot trust artifactapi
over HTTPS until it is installed. The package is therefore fetched over plain HTTP and
checked against `CA_RPM_SHA256` before `rpm -i`; everything after that uses HTTPS. The
pinned hash is the `pkgid` checksum recorded for the file in the repo's own repodata
(`repodata/repomd.xml` → `*-primary.xml.gz`).
## Make
make build # build every distro
make test # build the test stage of every distro (assertions run in-image)
make build-alma9 # one distro
make patch|minor|major
+12
View File
@@ -0,0 +1,12 @@
# alma9 — AlmaLinux 9
BASE_IMAGE="docker.io/almalinux:9"
IMAGE="almalinux9"
# Bootstrap of the internal CA. The image cannot trust artifactapi over HTTPS until
# this package is installed, so it is fetched over plain HTTP and verified against a
# pinned sha256 before rpm -i. The hash is the pkgid checksum recorded for this file
# in the rpm-vendor-el9 repodata (repodata/repomd.xml -> *-primary.xml.gz).
CA_RPM_URL="http://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/rpm-vendor-el9/Packages/unkin-ca-certificates-2025.7.13-1.el9.x86_64.rpm"
CA_RPM_SHA256="c12fb81ea639a92d19506e07e88df290635b15e175b1f8c063d4e8a2d86bab6e"
PACKAGES="git jq make python3.11 python3.11-pip python3.12 python3.12-pip uv wget"
+27
View File
@@ -0,0 +1,27 @@
[baseos]
name=baseos repository
baseurl=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/almalinux/$releasever/BaseOS/$basearch/os/
gpgkey=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/almalinux/$releasever/BaseOS/$basearch/os/RPM-GPG-KEY-AlmaLinux-9
enabled=1
gpgcheck=1
[appstream]
name=appstream repository
baseurl=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/almalinux/$releasever/AppStream/$basearch/os/
gpgkey=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/almalinux/$releasever/AppStream/$basearch/os/RPM-GPG-KEY-AlmaLinux-9
enabled=1
gpgcheck=1
[extras]
name=extras repository
baseurl=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/almalinux/$releasever/extras/$basearch/os/
gpgkey=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/almalinux/$releasever/extras/$basearch/os/RPM-GPG-KEY-AlmaLinux-9
enabled=1
gpgcheck=1
[crb]
name=crb repository
baseurl=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/almalinux/$releasever/CRB/$basearch/os/
gpgkey=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/almalinux/$releasever/CRB/$basearch/os/RPM-GPG-KEY-AlmaLinux-9
enabled=1
gpgcheck=1
+6
View File
@@ -0,0 +1,6 @@
[epel]
name=epel repository
baseurl=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/epel/$releasever/Everything/$basearch
gpgkey=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/epel/RPM-GPG-KEY-EPEL-$releasever
enabled=1
gpgcheck=1
+27
View File
@@ -0,0 +1,27 @@
[rpm-internal-el9]
name=rpm-internal-el9 repository
baseurl=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/rpm-internal-el9/
enabled=1
gpgcheck=0
metadata_expire=60
[rpm-vendor-el9]
name=rpm-vendor-el9 repository
baseurl=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/rpm-vendor-el9/
enabled=1
gpgcheck=0
metadata_expire=60
[rpm-internal]
name=rpm-internal repository
baseurl=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/rpm-internal/
enabled=1
gpgcheck=0
metadata_expire=60
[rpm-vendor]
name=rpm-vendor repository
baseurl=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/rpm-vendor/
enabled=1
gpgcheck=0
metadata_expire=60
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Guards the one mistake this layout invites: a distro added under distros/ but left
# out of a .woodpecker matrix, so it silently never gets built.
set -uo pipefail
REQUIRED_KEYS="BASE_IMAGE IMAGE CA_RPM_URL CA_RPM_SHA256 PACKAGES"
WORKFLOWS=".woodpecker/build.yaml .woodpecker/test.yaml .woodpecker/docker.yaml"
fail=0
for d in distros/*/; do
n=${d#distros/}; n=${n%/}
for k in $REQUIRED_KEYS; do
grep -q "^${k}=" "${d}distro.env" || { echo "${d}distro.env: missing ${k}"; fail=1; }
done
for wf in $WORKFLOWS; do
grep -q "DISTRO: ${n}\$" "$wf" || { echo "${wf}: distro ${n} missing from matrix"; fail=1; }
done
done
for s in ./*.sh; do bash -n "$s" || fail=1; done
[ "$fail" -eq 0 ] || { echo "FAILED"; exit 1; }
echo "PASS"
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# In-image assertions for the base image. Run via `docker build --target test`.
set -uo pipefail
ARTIFACTAPI=https://artifactapi.k8s.syd1.au.unkin.net
. ./distro.env
fail=0
check() {
local name=$1; shift
if out=$("$@" 2>&1); then
echo "ok $name"
else
echo "NOT OK $name"
printf '%s\n' "$out" | sed 's/^/ /'
fail=1
fi
}
# No -k and no --cacert: fails unless the bootstrapped internal CA is in the trust store.
check "internal CA trusted (artifactapi)" \
curl -fsS -o /dev/null "$ARTIFACTAPI/api/v1/local/rpm-vendor-el9/repodata/repomd.xml"
check "unkin-ca-certificates installed" rpm -q unkin-ca-certificates
# Every configured repo must refresh, and a package that exists only in the estate's
# local repos must resolve.
check "dnf refreshes every estate repo" dnf -q makecache --refresh
check "dnf resolves an estate-only package" \
dnf -q --disablerepo='*' --enablerepo='rpm-vendor*' list --available nfpm
for p in $PACKAGES; do
check "package $p installed" rpm -q "$p"
done
for c in git jq make wget uv python3.11 python3.12; do
check "$c runs" "$c" --version
done
[ "$fail" -eq 0 ] || { echo "FAILED"; exit 1; }
echo "PASS"