Skip to content

Docker refresh

Docker refresh #274

name: Docker refresh
on:
push:
branches:
- master
paths:
- .github/workflows/docker-refresh.yml
- scripts/ci/docker/Dockerfile*
- scripts/docker/build/*/Dockerfile.ci
- debian/control
- redhat/freeradius.spec
workflow_dispatch:
schedule:
- cron: '0 1 * * *'
concurrency:
#
# Only one refresh at a time. Lets in-flight runs finish rather than
# having a push trigger a cancel: an interrupted refresh leaves the
# registry partially updated, whereas a queued second run just
# re-publishes everything cleanly after the first one finishes.
#
group: docker-refresh
cancel-in-progress: false
env:
DOCKER_REGISTRY: "docker.internal.networkradius.com"
jobs:
#
# Stage 1: rebuild the runner-host base and the dind sidecar image
# from upstream. These can't run inside our own dind/cli container
# because that container is built on top of them.
#
process-host-bases:
timeout-minutes: 20
runs-on: self-hosted
if: github.repository_owner == 'FreeRADIUS' || github.event_name == 'workflow_dispatch'
strategy:
fail-fast: false
matrix:
os:
- base_image: ubuntu:24.04
display: self-hosted-ubuntu24
image_name: docker.internal.networkradius.com/self-hosted-ubuntu24
extra_tags:
- docker.internal.networkradius.com/self-hosted
dockerfile: scripts/ci/docker/Dockerfile
needs_build_deps: true
#
# Custom dind sidecar image used by the dind-based
# workflows (docker-service.yml, docker-crossbuild.yml, multi-server-tests).
# Bakes in registry-mirror -> internal cache and trusts the
# internal CA, so per-job apt-installing and daemon.json
# wiring goes away.
#
- base_image: docker:24-dind
display: self-hosted-docker-dind
image_name: docker.internal.networkradius.com/self-hosted-docker-dind
dockerfile: scripts/ci/docker/Dockerfile.docker-dind
needs_internal_ca: true
name: "${{ matrix.os.display }}"
steps:
- uses: actions/checkout@v6
with:
lfs: false
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_READ_USER }}
password: ${{ secrets.DOCKERHUB_READ_KEY }}
- name: Fetch and label image
env:
DOCKER_BASE_IMAGE: ${{ matrix.os.base_image }}
run: |
for attempt in 1 2 3; do
docker pull "$DOCKER_BASE_IMAGE" && break
if [ "$attempt" = 3 ]; then exit 1; fi
echo "Pull attempt $attempt failed; retrying after backoff..."
sleep 10
done
echo "FROM $DOCKER_BASE_IMAGE" | docker build --label preserve=true -t "$DOCKER_REGISTRY/$DOCKER_BASE_IMAGE" -
- name: Stage internal CA into build context
if: ${{ matrix.os.needs_internal_ca }}
run: |
cp /usr/local/share/ca-certificates/networkradius.com.crt \
scripts/ci/networkradius.com.crt
- name: Create build dependency package
if: ${{ matrix.os.needs_build_deps }}
env:
DOCKER_BASE_IMAGE: ${{ matrix.os.base_image }}
run: |
cat > build-dep-pkg.sh << EOF
apt-get update
export DEBIAN_FRONTEND=noninteractive
apt-get install -y --no-install-recommends build-essential devscripts equivs quilt
cd /work
debian/rules debian/control
mk-build-deps debian/control
mv freeradius-build-deps_*.deb freeradius-build-deps.deb
mk-build-deps scripts/ci/extra-packages.debian.control
mv freeradius-build-deps_1*.deb freeradius-build-deps-extra.deb
chown $(stat -c'%u:%g' .git) *
EOF
chmod 755 build-dep-pkg.sh
docker run \
-v ${{ github.workspace }}:/work \
-w /work \
--rm \
"$DOCKER_BASE_IMAGE" \
sh -c './build-dep-pkg.sh'
- name: Build main Docker image
env:
DOCKER_IMAGE_NAME: ${{ matrix.os.image_name }}
DOCKERFILE: ${{ matrix.os.dockerfile }}
run: |
for attempt in 1 2 3; do
docker build --no-cache -f "$DOCKERFILE" -t "$DOCKER_IMAGE_NAME" --label preserve=true . && exit 0
echo "Build attempt $attempt failed; retrying after backoff..."
sleep 10
done
exit 1
- name: Login to internal Docker registry for push
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_REPO_UPDATE_USERNAME }}
password: ${{ secrets.DOCKER_REPO_UPDATE_PASSWORD }}
registry: ${{ env.DOCKER_REGISTRY }}
- name: Push base image to local registry
env:
DOCKER_BASE_IMAGE: ${{ matrix.os.base_image }}
run: |
for attempt in 1 2 3; do
docker push "$DOCKER_REGISTRY/$DOCKER_BASE_IMAGE" && exit 0
echo "Push attempt $attempt failed; retrying after backoff..."
sleep 10
done
exit 1
- name: Push main image to local registry
env:
DOCKER_IMAGE_NAME: ${{ matrix.os.image_name }}
run: |
for attempt in 1 2 3; do
docker push "$DOCKER_IMAGE_NAME" && exit 0
echo "Push attempt $attempt failed; retrying after backoff..."
sleep 10
done
exit 1
- name: Create and push alternative tags
if: ${{ matrix.os.extra_tags }}
env:
DOCKER_IMAGE_NAME: ${{ matrix.os.image_name }}
DOCKER_TAGS: ${{ join(matrix.os.extra_tags, ' ') }}
run: |
for TAG in $DOCKER_TAGS; do
docker image tag "$DOCKER_IMAGE_NAME" "$TAG"
for attempt in 1 2 3; do
docker push "$TAG" && break
if [ "$attempt" = "3" ]; then exit 1; fi
echo "Push of $TAG attempt $attempt failed; retrying..."
sleep 10
done
done
#
# Stage 2: build the docker-cli image, which FROMs self-hosted (built
# in stage 1). Stages 3+ use this as their job container, so it has
# to be fresh before they start.
#
process-docker-cli:
needs: process-host-bases
timeout-minutes: 15
runs-on: self-hosted
if: github.repository_owner == 'FreeRADIUS' || github.event_name == 'workflow_dispatch'
strategy:
fail-fast: false
matrix:
os:
- parent_image: docker.internal.networkradius.com/self-hosted
display: self-hosted-docker-cli
image_name: docker.internal.networkradius.com/self-hosted-docker-cli
dockerfile: scripts/ci/docker/Dockerfile.docker-cli
name: "${{ matrix.os.display }}"
steps:
- uses: actions/checkout@v6
with:
lfs: false
- name: Login to internal Docker registry (read)
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_REPO_USERNAME }}
password: ${{ secrets.DOCKER_REPO_PASSWORD }}
registry: ${{ env.DOCKER_REGISTRY }}
- name: Pull parent image
env:
PARENT_IMAGE: ${{ matrix.os.parent_image }}
run: |
for attempt in 1 2 3; do
docker pull "$PARENT_IMAGE" && exit 0
echo "Pull attempt $attempt failed; retrying after backoff..."
sleep 10
done
exit 1
- name: Build derived image
env:
DOCKER_IMAGE_NAME: ${{ matrix.os.image_name }}
DOCKERFILE: ${{ matrix.os.dockerfile }}
run: |
for attempt in 1 2 3; do
docker build --no-cache -f "$DOCKERFILE" -t "$DOCKER_IMAGE_NAME" --label preserve=true . && exit 0
echo "Build attempt $attempt failed; retrying after backoff..."
sleep 10
done
exit 1
- name: Login to internal Docker registry for push
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_REPO_UPDATE_USERNAME }}
password: ${{ secrets.DOCKER_REPO_UPDATE_PASSWORD }}
registry: ${{ env.DOCKER_REGISTRY }}
- name: Push derived image
env:
DOCKER_IMAGE_NAME: ${{ matrix.os.image_name }}
run: |
for attempt in 1 2 3; do
docker push "$DOCKER_IMAGE_NAME" && exit 0
echo "Push attempt $attempt failed; retrying after backoff..."
sleep 10
done
exit 1
#
# Stage 3: refresh the remaining CI base images and the pull-through
# cache mirrors. Runs inside dind + the freshly-built docker-cli
# container.
#
process-ci-images:
needs: process-docker-cli
timeout-minutes: 20
runs-on: self-hosted
if: github.repository_owner == 'FreeRADIUS' || github.event_name == 'workflow_dispatch'
services:
dind:
image: docker.internal.networkradius.com/self-hosted-docker-dind
options: --privileged
env:
DOCKER_TLS_CERTDIR: ""
NO_PROXY: "*.networkradius.com,127.0.0.1"
volumes:
- ${{ github.workspace }}:/workspace
container:
image: docker.internal.networkradius.com/self-hosted-docker-cli
env:
DOCKER_HOST: tcp://dind:2375
NO_PROXY: dind,*.networkradius.com,127.0.0.1
volumes:
- /usr/local/share/ca-certificates/networkradius.com.crt:/usr/local/share/ca-certificates/networkradius.com.crt:ro
- ${{ github.workspace }}:/workspace
defaults:
run:
shell: bash
working-directory: /workspace
strategy:
fail-fast: false
matrix:
os:
#
# Slim build bases for ci-rpm.yml / ci-deb.yml. Generated from
# scripts/docker/m4/ci.{rpm,deb}.m4 - regenerate with
# `make dockerfile.ci` after editing the m4. The
# generated Dockerfile.ci hardcodes the per-distro FROM, so
# no build_args are needed here.
#
- base_image: rockylinux/rockylinux:9
display: self-hosted-ci-rocky9
image_name: docker.internal.networkradius.com/self-hosted-ci-rocky9
dockerfile: scripts/docker/build/rocky9/Dockerfile.ci
- base_image: rockylinux/rockylinux:10
display: self-hosted-ci-rocky10
image_name: docker.internal.networkradius.com/self-hosted-ci-rocky10
dockerfile: scripts/docker/build/rocky10/Dockerfile.ci
- base_image: debian:bookworm
display: self-hosted-ci-debian12
image_name: docker.internal.networkradius.com/self-hosted-ci-debian12
dockerfile: scripts/docker/build/debian12/Dockerfile.ci
- base_image: debian:trixie
display: self-hosted-ci-debian13
image_name: docker.internal.networkradius.com/self-hosted-ci-debian13
dockerfile: scripts/docker/build/debian13/Dockerfile.ci
- base_image: debian:sid
display: self-hosted-ci-debiansid
image_name: docker.internal.networkradius.com/self-hosted-ci-debiansid
dockerfile: scripts/docker/build/debiansid/Dockerfile.ci
- base_image: ubuntu:22.04
display: self-hosted-ci-ubuntu22
image_name: docker.internal.networkradius.com/self-hosted-ci-ubuntu22
dockerfile: scripts/docker/build/ubuntu22/Dockerfile.ci
- base_image: ubuntu:24.04
display: self-hosted-ci-ubuntu24
image_name: docker.internal.networkradius.com/self-hosted-ci-ubuntu24
dockerfile: scripts/docker/build/ubuntu24/Dockerfile.ci
- base_image: ubuntu:26.04
display: self-hosted-ci-ubuntu26
image_name: docker.internal.networkradius.com/self-hosted-ci-ubuntu26
dockerfile: scripts/docker/build/ubuntu26/Dockerfile.ci
- base_image: mariadb
display: mariadb
- base_image: postgres
display: postgres
- base_image: 4teamwork/389ds
display: 4teamwork/389ds
name: "${{ matrix.os.display }}"
steps:
- uses: actions/checkout@v6
with:
lfs: false
- name: setup-dind
uses: ./.github/actions/setup-dind
#
# Authenticate dind to Docker Hub up-front so every subsequent
# `docker pull` (and the implicit FROM-pulls triggered by
# `docker build`) uses the higher per-user rate limit instead
# of anonymous 100/6h. Skipped on parent_image-only entries
# since they pull from the internal registry instead.
#
- name: Login to Docker Hub (via dind)
if: ${{ matrix.os.base_image }}
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_READ_USER }}
password: ${{ secrets.DOCKERHUB_READ_KEY }}
#
# Read-only login to the internal registry. Needed up front so
# parent_image entries (cli) can pull their parent before the
# build step.
#
- name: Login to internal Docker registry (read, via dind)
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_REPO_USERNAME }}
password: ${{ secrets.DOCKER_REPO_PASSWORD }}
registry: ${{ env.DOCKER_REGISTRY }}
#
# Pull the source image into dind. base_image entries pull from
# Docker Hub then re-tag to the internal registry namespace so
# pull-through cache consumers see the same name. parent_image
# entries pull from the internal registry directly.
#
- name: Pull source image
env:
DOCKER_BASE_IMAGE: ${{ matrix.os.base_image }}
PARENT_IMAGE: ${{ matrix.os.parent_image }}
run: |
if [ -n "$PARENT_IMAGE" ]; then
for attempt in 1 2 3; do
docker pull "$PARENT_IMAGE" && exit 0
echo "Pull attempt $attempt failed; retrying after backoff..."
sleep 10
done
exit 1
fi
for attempt in 1 2 3; do
docker pull "$DOCKER_BASE_IMAGE" && break
if [ "$attempt" = 3 ]; then exit 1; fi
echo "Pull attempt $attempt failed; retrying after backoff..."
sleep 10
done
echo "FROM $DOCKER_BASE_IMAGE" | docker build --label preserve=true -t "$DOCKER_REGISTRY/$DOCKER_BASE_IMAGE" -
#
# Generate freeradius-build-deps*.deb so the slim CI base images
# can apt-install the package list without pulling the full
# build-essential / devscripts toolchain at every CI run. Only
# meaningful for the deb-family CI bases that build FreeRADIUS;
# pull-through DB caches, dind, and cli skip this.
#
- name: Create build dependency package
if: ${{ matrix.os.image_name && matrix.os.dockerfile && matrix.os.base_image }}
env:
DOCKER_BASE_IMAGE: ${{ matrix.os.base_image }}
run: |
cat > build-dep-pkg.sh << EOF
apt-get update
export DEBIAN_FRONTEND=noninteractive
apt-get install -y --no-install-recommends build-essential devscripts equivs quilt
cd /work
debian/rules debian/control
mk-build-deps debian/control
mv freeradius-build-deps_*.deb freeradius-build-deps.deb
mk-build-deps scripts/ci/extra-packages.debian.control
mv freeradius-build-deps_1*.deb freeradius-build-deps-extra.deb
chown $(stat -c'%u:%g' .git) *
EOF
chmod 755 build-dep-pkg.sh
docker run \
-v /workspace:/work \
-w /work \
--rm \
"$DOCKER_BASE_IMAGE" \
sh -c './build-dep-pkg.sh'
#
# Stage the internal CA cert into the build context for any
# matrix entry that asks for it (currently the dind sidecar
# image, which needs to trust docker.internal.networkradius.com).
#
- name: Stage internal CA into build context
if: ${{ matrix.os.needs_internal_ca }}
run: |
cp /usr/local/share/ca-certificates/networkradius.com.crt \
scripts/ci/networkradius.com.crt
#
# docker build's "exporting to image" phase occasionally trips a
# containerd snapshot-store lock race (transient, ~ms-scale).
# Three attempts with a short sleep is enough to absorb this
# without masking real failures.
#
- name: Build main Docker image
if: ${{ matrix.os.image_name && matrix.os.dockerfile }}
env:
DOCKER_IMAGE_NAME: ${{ matrix.os.image_name }}
DOCKERFILE: ${{ matrix.os.dockerfile }}
BUILD_ARGS_JSON: ${{ toJson(matrix.os.build_args) }}
run: |
build_args=()
while IFS=$'\t' read -r key value; do
[ -n "$key" ] && build_args+=(--build-arg "${key}=${value}")
done < <(printf '%s' "$BUILD_ARGS_JSON" | jq -r 'to_entries[]? | [.key, .value] | @tsv')
for attempt in 1 2 3; do
docker build --no-cache -f "$DOCKERFILE" -t "$DOCKER_IMAGE_NAME" --label preserve=true "${build_args[@]}" . && exit 0
echo "Build attempt $attempt failed; retrying after backoff..."
sleep 10
done
exit 1
#
# Switch to push-capable creds and publish.
#
- name: Login to internal Docker registry for push (via dind)
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_REPO_UPDATE_USERNAME }}
password: ${{ secrets.DOCKER_REPO_UPDATE_PASSWORD }}
registry: ${{ env.DOCKER_REGISTRY }}
- name: Push base image to local registry
if: ${{ matrix.os.base_image }}
env:
DOCKER_BASE_IMAGE: ${{ matrix.os.base_image }}
run: |
for attempt in 1 2 3; do
docker push "$DOCKER_REGISTRY/$DOCKER_BASE_IMAGE" && exit 0
echo "Push attempt $attempt failed; retrying after backoff..."
sleep 10
done
exit 1
- name: Push main image to local registry
if: ${{ matrix.os.image_name && matrix.os.dockerfile }}
env:
DOCKER_IMAGE_NAME: ${{ matrix.os.image_name }}
run: |
for attempt in 1 2 3; do
docker push "$DOCKER_IMAGE_NAME" && exit 0
echo "Push attempt $attempt failed; retrying after backoff..."
sleep 10
done
exit 1
- name: Create and push alternative tags
if: ${{ matrix.os.image_name && matrix.os.extra_tags }}
env:
DOCKER_IMAGE_NAME: ${{ matrix.os.image_name }}
DOCKER_TAGS: ${{ join(matrix.os.extra_tags, ' ') }}
run: |
for TAG in $DOCKER_TAGS; do
docker image tag "$DOCKER_IMAGE_NAME" "$TAG"
for attempt in 1 2 3; do
docker push "$TAG" && break
if [ "$attempt" = "3" ]; then exit 1; fi
echo "Push of $TAG attempt $attempt failed; retrying..."
sleep 10
done
done
#
# Periodic refresh of the FreeRADIUS base image chain in the
# internal registry. Builds for each supported distro in parallel:
#
# crossbuild FROM upstream OS base, full toolchain + FR
# build-deps
# profiling-deps FROM crossbuild:latest, toolchain layer
# (valgrind / kcachegrind / dbgsym / FlameGraph /
# inferno)
# service FROM the same upstream OS base as crossbuild
# (NOT from crossbuild) - the FR debian/rpm
# package build does its own apt-install of
# build deps; layering on crossbuild would
# bloat the runtime image with toolchain
# artefacts.
#
# Tags pushed: <prefix>-<type>/<image>:latest for each of those
# three.
#
# The `profiling` layer (FR compiled with callgrind-friendly CFLAGS
# on top of profiling-deps:latest) is deliberately NOT published
# here. It's per-commit by design - we want to profile specific
# commits, not whatever master HEAD was at refresh time. Multi-
# server profiling tests pull profiling-deps:latest and build the
# profiling layer locally per commit.
#
process-freeradius-images:
needs: process-docker-cli
timeout-minutes: 90
runs-on: self-hosted
if: github.repository_owner == 'FreeRADIUS' || github.event_name == 'workflow_dispatch'
strategy:
fail-fast: false
matrix:
os: [ubuntu22, ubuntu24, ubuntu26, debian12, debian13, debiansid, rocky9, rocky10]
services:
dind:
image: docker.internal.networkradius.com/self-hosted-docker-dind
options: --privileged
env:
DOCKER_TLS_CERTDIR: ""
NO_PROXY: "*.networkradius.com,127.0.0.1"
volumes:
- ${{ github.workspace }}:/workspace
container:
image: docker.internal.networkradius.com/self-hosted-docker-cli
env:
DOCKER_HOST: tcp://dind:2375
NO_PROXY: dind,*.networkradius.com,127.0.0.1
volumes:
- /usr/local/share/ca-certificates/networkradius.com.crt:/usr/local/share/ca-certificates/networkradius.com.crt:ro
- ${{ github.workspace }}:/workspace
defaults:
run:
shell: bash
working-directory: /workspace
name: "freeradius4-{crossbuild,profiling-deps,service}-${{ matrix.os }}"
steps:
- uses: actions/checkout@v6
with:
lfs: false
- name: setup-dind
uses: ./.github/actions/setup-dind
- name: Login to Docker Hub (via dind)
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_READ_USER }}
password: ${{ secrets.DOCKERHUB_READ_KEY }}
#
# Read-only login for any DOCKER_REGISTRY pull-through misses
# during the build (crossbuild itself FROMs upstream OS, not
# the internal registry).
#
- name: Login to internal Docker registry (read, via dind)
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_REPO_USERNAME }}
password: ${{ secrets.DOCKER_REPO_PASSWORD }}
registry: ${{ env.DOCKER_REGISTRY }}
#
# Build crossbuild + profiling-deps + service. crossbuild and
# service both FROM the upstream OS base; profiling-deps FROMs
# crossbuild:latest which DOCKER_BUILD writes locally as part
# of the crossbuild build, so the chain resolves in order
# without a registry pull between layers.
#
- name: Build crossbuild + profiling-deps + service images
env:
OS: ${{ matrix.os }}
run: |
make dockerfile.check
make docker.crossbuild.$OS
make docker.profiling-deps.$OS
make docker.service.$OS
- name: Show docker build logs
if: failure()
env:
OS: ${{ matrix.os }}
run: |
for type in crossbuild profiling-deps service; do
log="build/docker/build.$OS.$type"
if [ -f "$log" ]; then
echo "===== $log ====="
cat "$log"
fi
done
#
# Switch to push-capable creds and publish the three :latest
# tags. The per-commit `profiling` layer stays local.
#
- name: Login to internal Docker registry for push (via dind)
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_REPO_UPDATE_USERNAME }}
password: ${{ secrets.DOCKER_REPO_UPDATE_PASSWORD }}
registry: ${{ env.DOCKER_REGISTRY }}
- name: Push freeradius4-{crossbuild,profiling-deps,service}:latest
env:
OS: ${{ matrix.os }}
REGISTRY: docker.internal.networkradius.com
run: |
for type in crossbuild profiling-deps service; do
local_tag="freeradius4-${type}/${OS}:latest"
remote_tag="${REGISTRY}/${local_tag}"
docker tag "${local_tag}" "${remote_tag}"
for attempt in 1 2 3; do
docker push "${remote_tag}" && break
echo "Push attempt $attempt for ${remote_tag} failed; retrying after backoff..."
sleep 10
done
done