diff --git a/.woodpecker/build-ci-images.yml b/.woodpecker/build-ci-images.yml index 1d6c2b24..f7188c28 100644 --- a/.woodpecker/build-ci-images.yml +++ b/.woodpecker/build-ci-images.yml @@ -14,6 +14,12 @@ # Flux-deployed CronJobs, so it is tracked separately in #330 rather than # bundled here. Note this pipeline runs no LLM agent, so the exfil path that # motivated the other cutovers does not apply to it. +# +# The build steps run the plain kaniko executor, which has no shell: they pass +# flags via `entrypoint` and read credentials from DOCKER_CONFIG. Registry auth +# is written once by the registry-auth step below. Dated tags are literals: bump +# them in the commit that changes an image. The build timestamp itself lives in +# the image at /etc/image-build-date, written by a RUN in the Containerfile. when: - event: push path: @@ -24,10 +30,22 @@ when: evaluate: 'PIPELINE == "build-ci-images"' steps: - - name: build-ansible - image: gcr.io/kaniko-project/executor:v1.23.2-debug - directory: ci/images/ansible + - name: registry-auth + image: alpine:3.24.1 depends_on: [] + environment: + GITEA_USER: gitea_admin + GITEA_TOKEN: + from_secret: ci_images_token + commands: + - mkdir -p .docker + - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') + - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > .docker/config.json + + - name: build-ansible + image: gcr.io/kaniko-project/executor:v1.24.0 + directory: ci/images/ansible + depends_on: [registry-auth] # Only rebuild when THIS image's source (or the pipeline file) changed, or # on a manual run. Without this every step ran on any matching push, so a # secret-rotator commit needlessly rebuilt all four ~1.3G ci-* images and @@ -37,68 +55,47 @@ steps: - event: push path: ["ci/images/ansible/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/ci-ansible:$TAG \ - --destination gitea.renner.uno/gitea_admin/ci-ansible:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/ci-ansible:2026-08-23 + - --destination=gitea.renner.uno/gitea_admin/ci-ansible:latest - name: build-k8s - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/k8s - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/k8s/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/ci-k8s:$TAG \ - --destination gitea.renner.uno/gitea_admin/ci-k8s:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/ci-k8s:2026-08-23 + - --destination=gitea.renner.uno/gitea_admin/ci-k8s:latest - name: build-tofu - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/tofu - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/tofu/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/ci-tofu:$TAG \ - --destination gitea.renner.uno/gitea_admin/ci-tofu:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/ci-tofu:2026-08-23 + - --destination=gitea.renner.uno/gitea_admin/ci-tofu:latest # NOTE: ci-claude is deliberately NOT built here. It tracks upstream Claude # Code releases on its own cadence and is built + version-tagged by @@ -108,28 +105,21 @@ steps: # Runtime image, not CI tooling: a static musl binary in a FROM-scratch image, # used as the base for secret-rotation CronJobs. Replaces per-job inline Python. - name: build-secret-rotator - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: secret-rotator - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["secret-rotator/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/secret-rotator:$TAG \ - --destination gitea.renner.uno/gitea_admin/secret-rotator:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/secret-rotator:2026-08-23 + - --destination=gitea.renner.uno/gitea_admin/secret-rotator:latest # Runtime image, not CI tooling: a general-purpose alpine + jq base for # workloads that need to read JSON from a shell script. Named for what it IS, @@ -137,28 +127,21 @@ steps: # container, but nothing about it is zigbee-specific. Exists so tools come from # the image rather than an `apk add` on every container start. - name: build-alpine-jq - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/alpine-jq - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/alpine-jq/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/alpine-jq:$TAG \ - --destination gitea.renner.uno/gitea_admin/alpine-jq:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/alpine-jq:2026-08-23 + - --destination=gitea.renner.uno/gitea_admin/alpine-jq:latest # Runtime image, not CI tooling: upstream containernetworking/plugins binaries # baked in, consumed by the multus cni-plugins-installer DaemonSet. Replaces a @@ -166,28 +149,21 @@ steps: # critical path of every node rebuild. The download now happens here, once, # where the sha256 is checked — see the Containerfile for the full rationale. - name: build-cni-plugins - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/cni-plugins - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/cni-plugins/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/cni-plugins:$TAG \ - --destination gitea.renner.uno/gitea_admin/cni-plugins:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/cni-plugins:2026-08-23 + - --destination=gitea.renner.uno/gitea_admin/cni-plugins:latest # Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts # needing s_client/x509. Named for what it is; first consumer is the paperless @@ -195,28 +171,21 @@ steps: # every paperless pod start. The openssl CLI is genuinely absent from the alpine # base, so this one needs an image rather than a busybox substitute. - name: build-alpine-openssl - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/alpine-openssl - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/alpine-openssl/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \ - --destination gitea.renner.uno/gitea_admin/alpine-openssl:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/alpine-openssl:2026-08-23 + - --destination=gitea.renner.uno/gitea_admin/alpine-openssl:latest # Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image # run as two services on edge-1. Replaces pip-into-venv on the host, where an @@ -224,25 +193,18 @@ steps: # Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it # builds the same way and lands in the same registry so the audit sees it. - name: build-heating-can - image: gcr.io/kaniko-project/executor:v1.23.2-debug + image: gcr.io/kaniko-project/executor:v1.24.0 directory: ci/images/heating-can - depends_on: [] + depends_on: [registry-auth] when: - event: manual - event: push path: ["ci/images/heating-can/**", ".woodpecker/build-ci-images.yml"] environment: - GITEA_USER: gitea_admin - GITEA_TOKEN: - from_secret: ci_images_token - commands: - - mkdir -p /kaniko/.docker - - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') - - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json - - TAG=$(date +%Y-%m-%d) - - | - /kaniko/executor \ - --context . \ - --dockerfile Containerfile \ - --destination gitea.renner.uno/gitea_admin/heating-can:$TAG \ - --destination gitea.renner.uno/gitea_admin/heating-can:latest + DOCKER_CONFIG: "${CI_WORKSPACE}/.docker" + entrypoint: + - /kaniko/executor + - --context=. + - --dockerfile=Containerfile + - --destination=gitea.renner.uno/gitea_admin/heating-can:2026-08-23 + - --destination=gitea.renner.uno/gitea_admin/heating-can:latest diff --git a/ci/images/alpine-jq/Containerfile b/ci/images/alpine-jq/Containerfile index 3ef4d48d..5a94807f 100644 --- a/ci/images/alpine-jq/Containerfile +++ b/ci/images/alpine-jq/Containerfile @@ -22,3 +22,8 @@ FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d650 # unanchored extraction therefore yields a device link key, and herdsman then # forms a NEW Zigbee network and drops every device. RUN apk add --no-cache jq + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/alpine-openssl/Containerfile b/ci/images/alpine-openssl/Containerfile index e36de510..f06c8262 100644 --- a/ci/images/alpine-openssl/Containerfile +++ b/ci/images/alpine-openssl/Containerfile @@ -19,3 +19,8 @@ FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d650 # for paperless to trust. busybox has no s_client equivalent, so openssl is not # substitutable there. RUN apk add --no-cache openssl + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/ansible/Containerfile b/ci/images/ansible/Containerfile index decacc72..363a7c12 100644 --- a/ci/images/ansible/Containerfile +++ b/ci/images/ansible/Containerfile @@ -24,3 +24,8 @@ RUN pip3 install --break-system-packages --no-cache-dir \ RUN ansible-galaxy collection install \ community.proxmox \ community.docker + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/claude/Containerfile b/ci/images/claude/Containerfile index 0700cd43..c0c87f91 100644 --- a/ci/images/claude/Containerfile +++ b/ci/images/claude/Containerfile @@ -44,3 +44,9 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \ /usr \ /root \ /tmp + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# version was built, this says when. Deliberately the LAST layer and its own +# layer, so the byte-stable npm layer above still dedups in the registry — that +# dedup is what #351 was for, and a few dozen bytes here does not affect it. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/cni-plugins/Containerfile b/ci/images/cni-plugins/Containerfile index d42686e4..5bafa6eb 100644 --- a/ci/images/cni-plugins/Containerfile +++ b/ci/images/cni-plugins/Containerfile @@ -66,3 +66,8 @@ FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d650 # Alpine rather than scratch: the installer needs a shell to copy the binaries # onto the host. busybox covers cp/mv/chown/ls, so nothing is added. COPY --from=fetch /plugins/ /opt/cni-plugins/ + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/heating-can/Containerfile b/ci/images/heating-can/Containerfile index f9ea2118..741a0208 100644 --- a/ci/images/heating-can/Containerfile +++ b/ci/images/heating-can/Containerfile @@ -120,3 +120,8 @@ USER open3e WORKDIR /var/lib/open3e ENTRYPOINT ["supervisord", "-c", "/etc/supervisord.conf"] + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/k8s/Containerfile b/ci/images/k8s/Containerfile index c646bf0e..c18a4546 100644 --- a/ci/images/k8s/Containerfile +++ b/ci/images/k8s/Containerfile @@ -42,3 +42,8 @@ RUN curl -sfL -o /tmp/velero.tar.gz \ && tar -xz -C /tmp -f /tmp/velero.tar.gz \ && install "/tmp/velero-${VELERO_VERSION}-linux-amd64/velero" /usr/local/bin/velero \ && rm -rf "/tmp/velero-${VELERO_VERSION}-linux-amd64" /tmp/velero.tar.gz + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/ci/images/tofu/Containerfile b/ci/images/tofu/Containerfile index 4cfbafe0..c9516871 100644 --- a/ci/images/tofu/Containerfile +++ b/ci/images/tofu/Containerfile @@ -31,3 +31,8 @@ RUN pip3 install --break-system-packages --no-cache-dir \ RUN ansible-galaxy collection install \ community.proxmox \ community.docker + +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Last layer, so it only invalidates +# itself. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date diff --git a/secret-rotator/Containerfile b/secret-rotator/Containerfile index 128f673c..5ecf509c 100644 --- a/secret-rotator/Containerfile +++ b/secret-rotator/Containerfile @@ -21,8 +21,14 @@ COPY src ./src RUN cargo build --release --target x86_64-unknown-linux-musl \ && strip target/x86_64-unknown-linux-musl/release/secret-rotator +# Build timestamp, recorded in the image rather than the tag: the tag says which +# source revision was built, this says when. Written here because the final +# image is scratch and has no shell to run `date` in. +RUN date -u +%Y-%m-%dT%H:%M:%SZ > /build/image-build-date + FROM scratch COPY --from=build /build/target/x86_64-unknown-linux-musl/release/secret-rotator /secret-rotator +COPY --from=build /build/image-build-date /etc/image-build-date # Matches runAsUser on the rotation Jobs. There is no /etc/passwd in a scratch # image, so this is a bare uid by necessity — which is also all Kubernetes needs.