From d8de7617fbade4c63f0816059ef3bee1837784aa Mon Sep 17 00:00:00 2001 From: Oleks Date: Tue, 2 Jun 2026 09:29:38 +0300 Subject: [PATCH] ci(#192): thin .woodpecker via #196 escape-hatch (emmett#44) cms-plugins is an Astro/emdash web app whose image is built by npm/astro against an upstream package-lock.json (better-sqlite3 native build) and cannot be expressed as Nix on emmett/amd64, so it stays on docker buildx. Apply the cluster #196 OCI escape-hatch: move all build/tag/registry truth into ci/local.sh, parameterized by BUILDKIT_ADDR (local docker-container default, dry-run; CI overrides to the in-cluster native arm64 remote + PUBLISH=1). CI now runs the same script a developer runs, so CI and local can't drift. The three-tag Flux ImagePolicy contract (0.1., , -latest) and the arm64/kotkan targeting are preserved verbatim; the Dockerfile is unchanged. --- .woodpecker/container.yaml | 49 ++---- ci/local.sh | 305 +++++++++++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+), 36 deletions(-) create mode 100755 ci/local.sh diff --git a/.woodpecker/container.yaml b/.woodpecker/container.yaml index 3d6df8b..87f522d 100644 --- a/.woodpecker/container.yaml +++ b/.woodpecker/container.yaml @@ -5,6 +5,14 @@ # repo → pod rolls), and -latest (same image; chart image.tag # fallback). Only staging/production have an ImagePolicy, so only those # move pods. +# +# Thin wrapper (cluster #196, emmett#44): this is an Astro/emdash WEB app +# whose image is built by npm/astro against an upstream lockfile and can NOT +# be expressed as Nix on emmett/amd64, so it stays on `docker buildx`. CI +# runs the SAME ci/local.sh a developer runs — the only CI-specific bits are +# env overrides (BUILDKIT_ADDR -> in-cluster native-arch remote, PUBLISH=1). +# All the tag/registry semantics live in ci/local.sh so CI and local can't +# drift. labels: # kotkan (the deploy target) is an arm64 host, so we build natively on @@ -32,16 +40,11 @@ steps: environment: REGISTRY_TOKEN: from_secret: registry_token + # In-cluster native arm64 buildkit (kotkan's arch). ci/local.sh treats + # this as a native remote builder and skips qemu emulation. + BUILDKIT_ADDR: "tcp://buildkit-rootless-arm64.infra.svc.cluster.local:1234" + PUBLISH: "1" commands: - - BRANCH="$CI_COMMIT_BRANCH" - - SHA=$(echo "$CI_COMMIT_SHA" | cut -c1-12) - # Semver-shaped immutable tag, one per build. First two components - # stay 0.1 (no real semver discipline yet); patch is the pipeline - # number, monotonic across the whole repo. - - VERSION="0.1.$CI_PIPELINE_NUMBER" - - IMAGE="git.oleks.space/oleks/cms-plugins" - - 'echo "Building $IMAGE:$VERSION (branch=$BRANCH sha=$SHA linux/arm64)"' - # Wait for the in-cluster buildkit to be reachable (it can be cold). - | BUILDER_HOST="buildkit-rootless-arm64.infra.svc.cluster.local" @@ -54,33 +57,7 @@ steps: [ "$i" -eq 30 ] && echo "Builder not available" && exit 1 sleep 10 done - - - echo "$REGISTRY_TOKEN" | docker login git.oleks.space -u oleks --password-stdin - - docker buildx create --name cms-plugins-builder --driver remote "tcp://$BUILDER_HOST:$BUILDER_PORT" - - # Tagging scheme — every build pushes three refs: - # $VERSION — semver-shaped, one per build, immutable (audit). - # $BRANCH — floating channel pointer. THIS is what Flux's - # ImagePolicy tracks (filterTags `^staging$` / - # `^production$`, digestReflectionPolicy: Always); - # retagging it onto the new image is what makes - # ImageUpdateAutomation rewrite the pinned digest - # in the workloads repo and roll the pod. - # $BRANCH-latest — same image, kept only so the chart's cosmetic - # `image.tag` fallback (used when image.digest is - # unset) resolves to a real ref. - # All branches publish all three; only staging/production have an - # ImagePolicy, so only those actually move pods. - - | - TAGS="-t $IMAGE:$VERSION -t $IMAGE:$BRANCH -t $IMAGE:$BRANCH-latest" - docker buildx build \ - --builder cms-plugins-builder \ - --platform linux/arm64 \ - $TAGS \ - --push \ - . - - 'echo "Pushed $IMAGE:$VERSION + floated $IMAGE:$BRANCH and $IMAGE:$BRANCH-latest"' - + - ci/local.sh --arch arm64 backend_options: kubernetes: nodeSelector: diff --git a/ci/local.sh b/ci/local.sh new file mode 100755 index 0000000..5adc233 --- /dev/null +++ b/ci/local.sh @@ -0,0 +1,305 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# oci-image escape hatch (cluster #196) for a keep-Dockerfile repo. +# +# cms-plugins is an Astro/emdash WEB app: its production image is built by +# `npm ci` + `astro build` against an upstream package-lock.json with an +# apt-installed C toolchain (better-sqlite3 native build). It does NOT +# nixify and cannot be built on emmett/amd64 as Nix, so it stays on +# `docker buildx` instead of the parity-lib nix archetypes. +# +# This script is the single source of build truth: a developer runs it +# locally (default builder, dry-run) and CI runs the EXACT same script with +# a couple of env overrides, so CI and local can't drift (emmett#44). +# +# Builder is parameterized: +# BUILDKIT_ADDR default docker-container://local (dev: local buildkitd) +# CI overrides to the in-cluster native-arch remote, e.g. +# tcp://buildkit-rootless-arm64.infra.svc.cluster.local:1234 +# +# kotkan (the deploy target) is arm64, so the default target arch is arm64. +# Foreign-arch (an arch != the host) is emulated via qemu binfmt and is +# SLOW — an explicit notice is printed and, for a remote NATIVE-arch +# builder, emulation is skipped. If a foreign arch cannot be built locally +# (no binfmt handler, no remote builder) the blocker is named + exit != 0. +# +# DRY-RUN by default. Pass --publish (or PUBLISH=1) to actually --push. +# +# Tagging contract (PRESERVED verbatim from the old .woodpecker pipeline): +# every PUBLISH pushes three refs onto the one image: +# $VERSION — semver-shaped, one per build, immutable (audit). +# Default 0.1.$CI_PIPELINE_NUMBER (override via VERSION). +# $BRANCH — floating channel pointer Flux's ImagePolicy tracks. +# Retagging it is what rolls staging/production pods. +# $BRANCH-latest — same image; chart image.tag cosmetic fallback. +# Only staging/production have an ImagePolicy, so only those move pods. +# +# Token: $REGISTRY_TOKEN, else `pass `, else a named hard fail. +# The token is NEVER echoed and `set -x` is never enabled. + +set -euo pipefail + +# ---- configuration (override via env) --------------------------------------- +REGISTRY_HOST="${REGISTRY_HOST:-git.oleks.space}" +REGISTRY_OWNER="${REGISTRY_OWNER:-oleks}" +REGISTRY_USER="${REGISTRY_USER:-oleks}" +IMAGE_REPO="${IMAGE_REPO:-cms-plugins}" +PASS_ENTRY="${PASS_ENTRY:-infra/gitea/personal_access_token_packages_rw}" + +# Local buildkitd by default; CI overrides to the cluster native-arch remote. +BUILDKIT_ADDR="${BUILDKIT_ADDR:-docker-container://local}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# ---- argument parsing ------------------------------------------------------- +PUBLISH="${PUBLISH:-0}" +TARGET_ARCH="" + +usage() { + cat <<'EOF' +Usage: ci/local.sh [--arch ] [--publish] + + --arch Target arch: amd64 | arm64 | s390x (default: arm64, + the deploy target kotkan's arch) + --publish Actually push to the registry (default: DRY-RUN) + +Environment: + BUILDKIT_ADDR buildx builder endpoint + (default docker-container://local; CI -> cluster remote) + PUBLISH=1 same as --publish + REGISTRY_TOKEN registry token (else `pass PASS_ENTRY`) + VERSION immutable semver-shaped tag (default 0.1.$CI_PIPELINE_NUMBER) + CI_COMMIT_BRANCH branch -> floating channel tag (default current git branch) + +Examples: + ci/local.sh # dry-run, arm64, both via local buildkit + ci/local.sh --arch amd64 # dry-run, foreign arch via qemu (slow) + PUBLISH=1 ci/local.sh # real push of the arm64 image (3 tags) +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --arch) + TARGET_ARCH="${2:-}" + shift 2 + ;; + --publish) + PUBLISH=1 + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +# ---- arch resolution -------------------------------------------------------- +host_arch() { + case "$(uname -m)" in + x86_64) echo amd64 ;; + aarch64 | arm64) echo arm64 ;; + s390x) echo s390x ;; + *) echo "unknown" ;; + esac +} + +HOST_ARCH="$(host_arch)" +# kotkan is arm64; the image is only ever deployed there. +[ -n "$TARGET_ARCH" ] || TARGET_ARCH="arm64" + +case "$TARGET_ARCH" in +amd64 | arm64 | s390x) ;; +*) + echo "ERROR: unsupported --arch '$TARGET_ARCH' (want amd64|arm64|s390x)" >&2 + exit 2 + ;; +esac + +# ---- version + branch derivation -------------------------------------------- +# $VERSION -> $VERSION env -> 0.1.$CI_PIPELINE_NUMBER -> 0.1.0-dev. +derive_version() { + if [ -n "${VERSION:-}" ]; then + printf '%s' "$VERSION" + return + fi + if [ -n "${CI_PIPELINE_NUMBER:-}" ]; then + printf '0.1.%s' "$CI_PIPELINE_NUMBER" + return + fi + printf '0.1.0-dev' +} + +# $CI_COMMIT_BRANCH -> current git branch -> "dev". +derive_branch() { + if [ -n "${CI_COMMIT_BRANCH:-}" ]; then + printf '%s' "$CI_COMMIT_BRANCH" + return + fi + local b + if b="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)" \ + && [ -n "$b" ] && [ "$b" != "HEAD" ]; then + printf '%s' "$b" + return + fi + printf 'dev' +} + +IMAGE_VERSION="$(derive_version)" +BRANCH="$(derive_branch)" + +# ---- token resolution (never printed) --------------------------------------- +resolve_token() { + if [ -n "${REGISTRY_TOKEN:-}" ]; then + printf '%s' "$REGISTRY_TOKEN" + return 0 + fi + if command -v pass >/dev/null 2>&1; then + local t + if t="$(pass "$PASS_ENTRY" 2>/dev/null)" && [ -n "$t" ]; then + printf '%s' "$t" + return 0 + fi + fi + echo "BLOCKER: no registry token." >&2 + echo " Set \$REGISTRY_TOKEN or store it at: pass $PASS_ENTRY" >&2 + return 1 +} + +# ---- foreign-arch (qemu binfmt) gate ---------------------------------------- +binfmt_handler_present() { + local q="$1" + [ -e "/proc/sys/fs/binfmt_misc/qemu-${q}" ] +} + +qemu_name_for() { + case "$1" in + amd64) echo "x86_64" ;; + arm64) echo "aarch64" ;; + s390x) echo "s390x" ;; + esac +} + +# Is BUILDKIT_ADDR the matching per-arch cluster builder (native, no qemu)? +builder_is_native_remote() { + case "$BUILDKIT_ADDR" in + tcp://*"buildkit-"*"${TARGET_ARCH}"*) return 0 ;; + *) return 1 ;; + esac +} + +emulation_gate() { + if [ "$TARGET_ARCH" = "$HOST_ARCH" ]; then + return 0 + fi + if builder_is_native_remote; then + echo "NOTE: target $TARGET_ARCH served NATIVELY by remote builder ($BUILDKIT_ADDR)." + return 0 + fi + local q + q="$(qemu_name_for "$TARGET_ARCH")" + echo "================================================================" + echo " SLOW / ACCELERATOR NOTICE" + echo " Target arch '$TARGET_ARCH' != host '$HOST_ARCH'." + echo " This build runs under qemu-${q} binfmt emulation and will be" + echo " MUCH slower than a native build. For speed, point BUILDKIT_ADDR" + echo " at a native remote builder, e.g.:" + echo " BUILDKIT_ADDR=tcp://buildkit-rootless-${TARGET_ARCH}.infra.svc.cluster.local:1234" + echo "================================================================" + if ! binfmt_handler_present "$q"; then + echo "BLOCKER: cannot build '$TARGET_ARCH' locally — no qemu-${q} binfmt" >&2 + echo " handler is registered with this kernel." >&2 + echo " Fix one of:" >&2 + echo " * register emulation: docker run --privileged --rm tonistiigi/binfmt --install ${TARGET_ARCH}" >&2 + echo " * or use a native remote builder via BUILDKIT_ADDR (see notice above)." >&2 + return 1 + fi + return 0 +} + +# ---- buildx builder bootstrap ---------------------------------------------- +BUILDER_NAME="cc-local-${TARGET_ARCH}" + +ensure_builder() { + if docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then + return 0 + fi + case "$BUILDKIT_ADDR" in + docker-container://*) + docker buildx create --name "$BUILDER_NAME" \ + --driver docker-container >/dev/null + ;; + tcp://*) + local endpoint="${BUILDKIT_ADDR#tcp://}" + docker buildx create --name "$BUILDER_NAME" \ + --driver remote "tcp://${endpoint}" >/dev/null + ;; + *) + echo "ERROR: unrecognized BUILDKIT_ADDR scheme: $BUILDKIT_ADDR" >&2 + echo " want docker-container://... or tcp://host:port" >&2 + exit 2 + ;; + esac +} + +# ---- build / publish -------------------------------------------------------- +# Builds the single repo-root Dockerfile and (on --publish) pushes the three +# contract tags onto it in one buildx invocation. +build_image() { + local image="${REGISTRY_HOST}/${REGISTRY_OWNER}/${IMAGE_REPO}" + + local -a args=( + docker buildx build + --builder "$BUILDER_NAME" + --platform "linux/${TARGET_ARCH}" + -f "${REPO_ROOT}/Dockerfile" + -t "${image}:${IMAGE_VERSION}" + -t "${image}:${BRANCH}" + -t "${image}:${BRANCH}-latest" + ) + + if [ "$PUBLISH" = "1" ]; then + args+=(--push) + echo ">> PUBLISH $image tags: ${IMAGE_VERSION}, ${BRANCH}, ${BRANCH}-latest" + else + echo ">> DRY-RUN $image tags: ${IMAGE_VERSION}, ${BRANCH}, ${BRANCH}-latest (build-only; pass --publish to push)" + fi + args+=("$REPO_ROOT") + + "${args[@]}" +} + +# ---- main ------------------------------------------------------------------- +main() { + echo "repo : ${REGISTRY_HOST}/${REGISTRY_OWNER}/${IMAGE_REPO}" + echo "host arch : ${HOST_ARCH}" + echo "target arch : ${TARGET_ARCH}" + echo "branch : ${BRANCH}" + echo "version tag : ${IMAGE_VERSION}" + echo "builder : ${BUILDKIT_ADDR}" + echo "mode : $([ "$PUBLISH" = "1" ] && echo PUBLISH || echo DRY-RUN)" + + emulation_gate + + if [ "$PUBLISH" = "1" ]; then + local token + token="$(resolve_token)" + echo "$token" | docker login "$REGISTRY_HOST" \ + -u "$REGISTRY_USER" --password-stdin >/dev/null + unset token + fi + + ensure_builder + build_image + + echo "done." +} + +main