summaryrefslogtreecommitdiffstats
path: root/image-builder
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-07-14 16:59:44 +0200
committerDanilo M. <danix@danix.xyz>2026-07-14 16:59:44 +0200
commit3cd610c212076d438f2d1103e0fc584a310011d5 (patch)
treeb82bd71ff139408dc84f685345862f944653966b /image-builder
downloadsbo-dockerbuild-3cd610c212076d438f2d1103e0fc584a310011d5.tar.gz
sbo-dockerbuild-3cd610c212076d438f2d1103e0fc584a310011d5.zip
initial commit: docker test-build toolchain
Extracted from the .extras/ dir of the sbo-slackbuilds package repo, where it grew from a throwaway helper into a standalone tool. Git history starts fresh; the original 41-commit development log is preserved in HISTORY.md. Contents: the test-build CLI (resolves a local SlackBuild's deps from a configured SBo tree and builds it in a throwaway container, lints, caches deps), the image-builder chain that produces the images it consumes, their pure-logic self-checks, design specs/plans, an install.sh for ~/bin, and docs. Licensed GPLv2-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'image-builder')
-rw-r--r--image-builder/README69
-rwxr-xr-ximage-builder/bootstrap.sh564
-rwxr-xr-ximage-builder/build-full-image.sh245
-rwxr-xr-ximage-builder/build-sbo-testbuild.sh143
-rw-r--r--image-builder/config15
-rw-r--r--image-builder/lib.sh84
-rwxr-xr-ximage-builder/test-image-builder.sh80
7 files changed, 1200 insertions, 0 deletions
diff --git a/image-builder/README b/image-builder/README
new file mode 100644
index 0000000..8711b1d
--- /dev/null
+++ b/image-builder/README
@@ -0,0 +1,69 @@
+sbo-testbuild image builder
+===========================
+
+Builds the docker images that test-build consumes:
+ docker.noland.dnx:5000/sbo-testbuild:current
+ docker.noland.dnx:5000/sbo-testbuild:15.0
+
+Three scripts, chained (see docs/specs/2026-07-13-image-builder-design.md):
+ bootstrap.sh sbo-base:{ver} FROM scratch, base pkgs from NAS
+ build-full-image.sh sbo-full:{ver} FROM base, all series
+ build-sbo-testbuild.sh sbo-testbuild:{ver} FROM full, + sbopkg + tools
+
+All settings live in ./config.
+
+VM setup (docker.noland.dnx, Slackware x86_64, 4 vCPU / 4 GB / 80 GB)
+--------------------------------------------------------------------
+1. Install docker; enable the daemon.
+
+2. NFS-mount the two NAS trees read-only, named to match:
+ /mnt/nas/slackware64-current -> -current mirror tree
+ /mnt/nas/slackware64-15.0 -> 15.0 mirror tree
+ Each is a full mirror (PACKAGES.TXT, ChangeLog.txt, slackware64/, patches/,
+ extra/). Root must be able to read them (bootstrap runs installpkg as root).
+
+3. Run a LAN registry:
+ docker run -d --restart=always -p 5000:5000 \
+ -v /opt/registry/data:/var/lib/registry --name registry registry:2
+
+4. Mark the registry insecure (plain HTTP) on the VM AND every pulling client
+ (this dev box, the buildsystem VM). In /etc/docker/daemon.json:
+ { "insecure-registries": ["docker.noland.dnx:5000"] }
+ then restart docker.
+
+5. Drop the two prebuilt packages (built once, re-drop on upstream bumps):
+ /opt/sbo-testbuild/pkgs/sbopkg-*.txz
+ /opt/sbo-testbuild/pkgs/sbo-maintainer-tools-*.txz
+
+6. Install the nightly cron (root). The NAS repos sync at 01:00 and 02:00, so
+ the chain runs after and both variants are ready well before the ~09:00 work
+ start. No --force: each script self-gates (bootstrap on the ChangeLog hash,
+ full-image on the base-image digest, build-sbo-testbuild on the full-image
+ digest + tools .txz hash), so an unchanged night is a cheap no-op. -current
+ moves daily and rebuilds most nights; 15.0 is frozen stable and rebuilds only
+ on a real repo update. Deployed schedule on docker.noland.dnx:
+ # -current (ready ~04:35)
+ 0 3 * * * /path/to/sbo-dockerbuild/image-builder/bootstrap.sh --version current >> /var/log/sbo-testbuild.log 2>&1
+ 20 3 * * * /path/to/sbo-dockerbuild/image-builder/build-full-image.sh --version current >> /var/log/sbo-testbuild.log 2>&1
+ 30 4 * * * /path/to/sbo-dockerbuild/image-builder/build-sbo-testbuild.sh --version current >> /var/log/sbo-testbuild.log 2>&1
+ # 15.0 (ready ~06:35)
+ 0 5 * * * /path/to/sbo-dockerbuild/image-builder/bootstrap.sh --version 15.0 >> /var/log/sbo-testbuild.log 2>&1
+ 20 5 * * * /path/to/sbo-dockerbuild/image-builder/build-full-image.sh --version 15.0 >> /var/log/sbo-testbuild.log 2>&1
+ 30 6 * * * /path/to/sbo-dockerbuild/image-builder/build-sbo-testbuild.sh --version 15.0 >> /var/log/sbo-testbuild.log 2>&1
+
+7. Ensure docker.noland.dnx resolves on the LAN (static IP or DNS).
+
+Manual first run
+----------------
+ ./bootstrap.sh --version current --force
+ ./build-full-image.sh --version current --force
+ ./build-sbo-testbuild.sh --version current --force
+Then confirm:
+ docker pull docker.noland.dnx:5000/sbo-testbuild:current
+ docker run --rm docker.noland.dnx:5000/sbo-testbuild:current sbopkg -V
+
+Flags: --force (rebuild unconditionally), --version <current|15.0> (one variant).
+
+Tests
+-----
+ bash test-image-builder.sh # pure-logic self-check, no docker
diff --git a/image-builder/bootstrap.sh b/image-builder/bootstrap.sh
new file mode 100755
index 0000000..273afc8
--- /dev/null
+++ b/image-builder/bootstrap.sh
@@ -0,0 +1,564 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# bootstrap.sh — build the sbo-base:{ver} image FROM scratch from NAS trees.
+# Run as root (installpkg). Adapted from forge slackware/docker-images.
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+source "${HERE}/config"
+LOG_TAG=bootstrap
+source "${HERE}/lib.sh"
+
+REGISTRY_IMAGE="${REGISTRY}/sbo-base"
+[[ "${EUID}" -eq 0 ]] || _err "run as root."
+mkdir -p "${HASH_DIR}"
+
+# ============================================================================
+# Package list
+#
+# Names only - the version and build suffix are resolved automatically
+# from PACKAGES.TXT for the x86_64 target architecture.
+#
+# slackpkg and its runtime dependencies (perl, gnupg2, gpgme) are included
+# so users can run slackpkg inside the container without extra setup.
+# ============================================================================
+
+PACKAGES=(
+ # Base system
+ aaa_base
+ coreutils
+ aaa_glibc-solibs
+ aaa_libraries
+ aaa_terminfo
+ pam
+ cracklib
+ libpwquality
+ acl
+ attr
+ bash
+ bin
+ brotli
+ bzip2
+ c-ares
+ cpio
+ curl
+ cyrus-sasl
+ devs
+ dialog
+ diffutils
+ duktape
+ elvis
+ etc
+ file
+ findutils
+ flex
+ gawk
+ gnutls
+ grep
+ groff
+ gzip
+ iproute2
+ iptables
+ jansson
+ less
+ libcgroup
+ libpsl
+ librsvg
+ libtermcap
+ mtr
+ ncurses
+ net-tools
+ network-scripts
+ nghttp2
+ nghttp3
+ ngtcp2
+ nvi
+ openssh
+ patch
+ pcre2
+ pinentry
+ pkgtools
+ polkit
+ procps-ng
+ quota
+ rsync
+ screen
+ sed
+ elogind
+ shadow
+ sharutils
+ strace
+ sudo
+ sysfsutils
+ sysvinit
+ sysvinit-scripts
+ tar
+ time
+ tree
+ eudev
+ usbutils
+ utempter
+ util-linux
+ wget
+ which
+ whois
+ xz
+ zlib
+ # Package management - series ap
+ slackpkg
+ # Perl runtime required by slackpkg - series d
+ perl
+ # e2fsprogs provides libcom_err.so.2 required by perl on 15.0
+ e2fsprogs
+ # libunistring - Unicode string library required by gnupg2 dependencies
+ libunistring
+ # gnupg2 runtime libraries (series l) - required for gpg to start at all
+ libgpg-error
+ libgcrypt
+ libassuan
+ libksba
+ npth
+ sqlite
+ icu4c
+ # GPG/PKCS support for slackpkg (series l)
+ gpgme
+ # GnuPG binaries (series n)
+ # gnupg = version 1, called by slackpkg on Slackware 15.0
+ # gnupg2 = version 2, called by slackpkg on -current
+ gnupg
+ gnupg2
+ # Full OpenSSL and CA bundle - required for HTTPS inside the container
+ openssl
+ ca-certificates
+)
+
+# ============================================================================
+# Argument parsing
+# ============================================================================
+
+FORCE=false
+OPT_VERSION=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version) OPT_VERSION="$2"; shift 2 ;;
+ --force) FORCE=true; shift ;;
+ *) _err "unknown argument: $1" ;;
+ esac
+done
+if [[ -n "${OPT_VERSION}" ]]; then
+ BUILD_VARIANTS=("${OPT_VERSION}")
+else
+ BUILD_VARIANTS=("${VARIANTS[@]}")
+fi
+
+# ============================================================================
+# ChangeLog tracking
+# ============================================================================
+
+# changelog_changed VERSION -> 0 if changed (or --force), else 1
+changelog_changed() {
+ local version="$1"
+ require_mount "${version}" # loud fail if NAS unmounted, not silent skip
+ local repo_key="slackware64-${version}"
+ local hash_file="${HASH_DIR}/${repo_key}.sha256"
+ local url; url="$(mirror_path "${MIRROR}" "${repo_key}/ChangeLog.txt")"
+ local tmp; tmp="$(mktemp)"
+ if ! fetch "${url}" "${tmp}"; then
+ rm -f "${tmp}"
+ _warn "${repo_key}: cannot read ChangeLog (${url}); skipping."
+ return 1
+ fi
+ local live; live=$(sha256sum "${tmp}" | cut -d' ' -f1); rm -f "${tmp}"
+ if [[ "${FORCE}" == "true" ]]; then
+ echo "${live}" > "${hash_file}"; return 0
+ fi
+ local stored=""; [[ -f "${hash_file}" ]] && stored=$(cat "${hash_file}")
+ if [[ "${live}" == "${stored}" ]]; then
+ _log "${repo_key}: ChangeLog unchanged; skipping."; return 1
+ fi
+ _log "${repo_key}: ChangeLog changed."; echo "${live}" > "${hash_file}"; return 0
+}
+
+# ============================================================================
+# Package discovery via PACKAGES.TXT
+#
+# Search order: patches/ -> main repo -> extra/
+# This mirrors Slackware's own install priority: a patched package in
+# patches/ always takes precedence over the same package in the main tree.
+# ============================================================================
+
+# download_pkgtxt PKGTXT_DIR PKG_PATH
+# Downloads PACKAGES.TXT from patches/, the repo root, and extra/ into
+# PKGTXT_DIR. Sets global associative array PKGTXT[loc] -> local file path.
+# Uses -L to follow HTTP redirects (some mirrors use them) and checks that
+# the downloaded file is non-empty before marking it as available.
+declare -gA PKGTXT=()
+
+download_pkgtxt() {
+ local PKGTXT_DIR="$1"
+ local PKG_PATH="$2"
+ mkdir -p "${PKGTXT_DIR}"
+ PKGTXT=()
+ local LOC URL DEST
+ for LOC in patches main extra; do
+ case "${LOC}" in
+ patches) URL="${PKG_PATH}/patches/PACKAGES.TXT" ;;
+ main) URL="${PKG_PATH}/PACKAGES.TXT" ;;
+ extra) URL="${PKG_PATH}/extra/PACKAGES.TXT" ;;
+ esac
+ DEST="${PKGTXT_DIR}/${LOC}.txt"
+ if fetch "${URL}" "${DEST}" && [[ -s "${DEST}" ]]; then
+ PKGTXT["${LOC}"]="${DEST}"
+ _log " PACKAGES.TXT [${LOC}]: $(wc -l < "${DEST}") lines"
+ else
+ rm -f "${DEST}"
+ _log " PACKAGES.TXT [${LOC}]: not available at ${URL}"
+ fi
+ done
+}
+
+# find_package NAME
+# Searches PKGTXT files in order: patches -> main -> extra.
+# Prints the path relative to PKG_PATH, e.g.:
+# slackware64/a/bash-5.2-x86_64-1.txz
+#
+# Base name extraction uses the Slackware convention: the filename has the
+# form PKGBASE-VERSION-ARCH-BUILD.txz where VERSION, ARCH and BUILD are
+# guaranteed to contain no dashes. Reversing, cutting the last three fields,
+# and reversing again always yields the correct PKGBASE:
+# echo aaa_glibc-solibs-2.42-x86_64-1.txz | rev | cut -d- -f4- | rev
+# -> aaa_glibc-solibs
+find_package() {
+ local PKG="$1"
+ [[ "${#PKGTXT[@]}" -gt 0 ]] || {
+ _warn "find_package called with empty PKGTXT array - PACKAGES.TXT not loaded"
+ return 1
+ }
+ for LOC in patches main extra; do
+ [[ -v "PKGTXT[${LOC}]" ]] || continue
+ local RESULT
+ RESULT=$(awk -v want="${PKG}" '
+ /^PACKAGE NAME:/ {
+ filename = $NF
+ # PKGBASE = all fields except the last three (VERSION ARCH BUILD)
+ n = split(filename, parts, "-")
+ base = parts[1]
+ for (i = 2; i <= n-3; i++) base = base "-" parts[i]
+ if (base == want) found = filename
+ }
+ /^PACKAGE LOCATION:/ && found {
+ loc = $NF; sub(/^\.\//, "", loc)
+ print loc "/" found
+ found = ""; exit
+ }
+ ' "${PKGTXT[${LOC}]}")
+ if [[ -n "${RESULT}" ]]; then
+ echo "${RESULT}"
+ return 0
+ fi
+ done
+ return 1
+}
+
+# ============================================================================
+# Build one variant
+# ============================================================================
+
+build_variant() {
+ local VERSION="$1"
+ require_mount "${VERSION}"
+ local REPO_KEY="slackware64-${VERSION}"
+ local PKG_PATH; PKG_PATH="$(mirror_path "${MIRROR}" "${REPO_KEY}")"
+ local TAG="${REGISTRY_IMAGE}:${VERSION}"
+ # Bake the LAN HTTP mirror into the base image: the full-image build serves
+ # the NFS mirror over HTTP on the bridge (see build-full-image.sh), rooted
+ # at the variant's mirror tree, so slackpkg pulls from LAN not the internet.
+ # The URL is variant-agnostic (the server's --directory selects the tree).
+ local SLACKPKG_MIRROR="http://${HTTP_MIRROR_HOST}:${HTTP_MIRROR_PORT}/"
+
+ _log "=== Building ${TAG} from ${PKG_PATH} ==="
+
+ local WORKDIR
+ WORKDIR="$(mktemp -d /tmp/slackware-bootstrap.XXXXXX)"
+ # shellcheck disable=SC2064
+ trap "rm -rf '${WORKDIR}'" EXIT
+
+ local ROOTFS="${WORKDIR}/rootfs"
+ local PKGCACHE="${WORKDIR}/packages"
+ local PKGTXT_DIR="${WORKDIR}/pkgtxt"
+ mkdir -p "${ROOTFS}" "${PKGCACHE}"
+
+ # --- Download PACKAGES.TXT index files ---
+ download_pkgtxt "${PKGTXT_DIR}" "${PKG_PATH}"
+
+ # --- Download packages ---
+ _log "Locating and downloading packages..."
+ local PKG RELPATH FILENAME URL
+ for PKG in "${PACKAGES[@]}"; do
+ RELPATH=$(find_package "${PKG}") || {
+ _warn " '${PKG}' not found in any PACKAGES.TXT - skipping"
+ continue
+ }
+ FILENAME="${RELPATH##*/}"
+ URL="${PKG_PATH}/${RELPATH}"
+ _log " ${FILENAME}"
+ fetch "${URL}" "${PKGCACHE}/${FILENAME}" || {
+ _warn " Failed to download ${URL}"
+ }
+ done
+
+ # --- Install packages ---
+ _log "Installing packages..."
+ for PKG in "${PKGCACHE}"/*.txz; do
+ [[ -f "${PKG}" ]] || continue
+ ROOT="${ROOTFS}" installpkg --terse "${PKG}"
+ done
+
+ # --- Run ldconfig to create library symlinks ---
+ # Some packages (notably aaa_libraries on stable releases) install
+ # versioned library files (e.g. libtinfo.so.6.3) and rely on ldconfig
+ # to create the unversioned symlinks (e.g. libtinfo.so.6). On -current
+ # the symlinks are included in the package tarballs directly; on 15.0
+ # they are not. Running ldconfig here ensures the symlinks exist before
+ # any chroot operations and before the dependency check below.
+ _log "Running ldconfig..."
+ chroot "${ROOTFS}" /sbin/ldconfig
+
+ # --- Check for missing library dependencies ---
+ # Run ldd on key binaries inside the chroot and report anything missing
+ # before it surfaces as a runtime failure inside the container.
+ _log "Checking for missing library dependencies in key binaries..."
+ local DEPCHECK_OK=true
+ for BIN in /usr/bin/gpg /usr/bin/gpg2 /usr/sbin/slackpkg /usr/bin/perl /bin/wget /usr/bin/openssl; do
+ [[ -f "${ROOTFS}${BIN}" ]] || continue
+ local MISSING_LIBS
+ MISSING_LIBS=$(chroot "${ROOTFS}" ldd "${BIN}" 2>/dev/null | grep "not found" || true)
+ if [[ -n "${MISSING_LIBS}" ]]; then
+ _warn "${BIN} has unresolved dependencies:"
+ while IFS= read -r lib; do
+ _warn " ${lib}"
+ done <<< "${MISSING_LIBS}"
+ DEPCHECK_OK=false
+ fi
+ done
+ if [[ "${DEPCHECK_OK}" == "true" ]]; then
+ _log "Dependency check passed."
+ else
+ _warn "Add the missing packages to PACKAGES and rebuild with --force."
+ fi
+
+ # --- Prepare rootfs for container use ---
+ _log "Configuring rootfs for container use..."
+
+ # Remove the empty update.d hook directory so that update-ca-certificates
+ # does not look for run-parts (which ships with dcron, not installed here).
+ rm -rf "${ROOTFS}/etc/ca-certificates/update.d"
+
+ # Disable init scripts that manage hardware absent in a container
+ local SVC
+ for SVC in rc.acpid rc.pcmcia rc.setterm rc.udev; do
+ [[ -f "${ROOTFS}/etc/rc.d/${SVC}" ]] && chmod -x "${ROOTFS}/etc/rc.d/${SVC}"
+ done
+
+ # Each container generates its own SSH host keys on first start
+ rm -f "${ROOTFS}"/etc/ssh/*key*
+
+ # /etc/mtab should reflect the container's actual mounts
+ rm -f "${ROOTFS}/etc/mtab"
+ ln -s /proc/mounts "${ROOTFS}/etc/mtab"
+
+ # No hardware clock in a container
+ sed -i -e '/^if \[ -x \/sbin\/hwclock/,/^fi$/s/^/#/' \
+ "${ROOTFS}/etc/rc.d/rc.S" 2>/dev/null || true
+
+ # The filesystem write-check would drop the container into a recovery shell
+ sed -i -e '/^if touch \/fsrwtestfile/,/^fi$/s/^/#/' \
+ "${ROOTFS}/etc/rc.d/rc.S" 2>/dev/null || true
+
+ # Skip all filesystem checks at boot
+ touch "${ROOTFS}/etc/fastboot"
+
+ # setterm is not useful in a container
+ sed -i -e '/\/bin\/setterm/s/^/# /' \
+ "${ROOTFS}/etc/rc.d/rc.M" 2>/dev/null || true
+
+ # Cannot write to the hardware clock at shutdown
+ sed -i -e '/systohc/s/^/# /' \
+ "${ROOTFS}/etc/rc.d/rc.6" 2>/dev/null || true
+
+ # Container-appropriate fstab
+ cat > "${ROOTFS}/etc/fstab" <<'FSTAB'
+devtmpfs /dev devtmpfs defaults 0 0
+devpts /dev/pts devpts gid=5,mode=620 0 0
+tmpfs /dev/shm tmpfs defaults,nodev,nosuid,mode=1777 0 0
+FSTAB
+
+ # No TTYs in a container; disable agetty and reduce console definitions
+ sed -i -e '/agetty/s/^c/#c/' \
+ "${ROOTFS}/etc/inittab" 2>/dev/null || true
+ sed -i -e '/^c3\|^c4\|^c5\|^c6/s/^/# /' \
+ "${ROOTFS}/etc/inittab" 2>/dev/null || true
+
+ # Invalidate the root password (containers use other means of access)
+ sed -i -e '/^root/s/^root::/root:!:/' \
+ "${ROOTFS}/etc/shadow" 2>/dev/null || true
+
+ # Terminal configuration
+ printf 'export TERM=linux\n' > "${ROOTFS}/etc/profile.d/term.sh"
+ chmod +x "${ROOTFS}/etc/profile.d/term.sh"
+
+ # Source /etc/profile from .bashrc so interactive sessions are fully
+ # initialised (Docker sets / as the home directory for root in containers)
+ printf '. /etc/profile\n' > "${ROOTFS}/.bashrc"
+
+ # DNS resolver
+ printf 'nameserver 1.1.1.1\nnameserver 8.8.4.4\n' \
+ >> "${ROOTFS}/etc/resolv.conf"
+
+ # Configure slackpkg for silent non-interactive use in a container
+ if [[ -f "${ROOTFS}/etc/slackpkg/slackpkg.conf" ]]; then
+ sed -i 's/DIALOG=on/DIALOG=off/' "${ROOTFS}/etc/slackpkg/slackpkg.conf"
+ sed -i 's/POSTINST=on/POSTINST=off/' "${ROOTFS}/etc/slackpkg/slackpkg.conf"
+ sed -i 's/SPINNING=on/SPINNING=off/' "${ROOTFS}/etc/slackpkg/slackpkg.conf"
+ sed -i 's/WGETFLAGS="--passive-ftp/& --no-verbose/' \
+ "${ROOTFS}/etc/slackpkg/slackpkg.conf"
+ fi
+
+ # Configure the slackpkg mirror
+ [[ -f "${ROOTFS}/etc/slackpkg/mirrors" ]] && \
+ printf '%s\n' "${SLACKPKG_MIRROR}" >> "${ROOTFS}/etc/slackpkg/mirrors"
+
+ # slackpkg requires an explicit opt-in for -current
+ if [[ "${VERSION}" == "current" ]]; then
+ mkdir -p "${ROOTFS}/var/lib/slackpkg"
+ touch "${ROOTFS}/var/lib/slackpkg/current"
+ fi
+
+ # Set a predictable locale for all chroot operations.
+ # Without this, perl emits locale warnings when the host locale
+ # (e.g. en_US.UTF-8) is not installed in the minimal rootfs.
+ export LC_ALL=C
+
+ # Rebuild the CA certificate database inside the rootfs.
+ # Without this, wget cannot verify Let's Encrypt certificates and every
+ # HTTPS download by slackpkg fails with an SSL error. That SSL error in
+ # turn causes the interactive "do you want to import the GPG key?" prompt
+ # even when -batch=on -default_answer=y is set.
+ _log "Refreshing CA certificate database..."
+ chroot "${ROOTFS}" /usr/sbin/update-ca-certificates --fresh 1>/dev/null || _warn "CA certificate update failed; SSL verification may not work correctly."
+
+ # Import the Slackware GPG key and update the package list.
+ # 'yes YES |' feeds a pre-emptive answer to the interactive import prompt
+ # as a safety net; with the CA database now valid the prompt should not
+ # appear because the download succeeds on the first attempt.
+ _log "Importing Slackware GPG key via slackpkg..."
+ # Check slackpkg's own exit status, not the pipeline's: 'yes' takes SIGPIPE
+ # when slackpkg exits first, which can make the pipeline non-zero even on
+ # success (false "GPG update failed" warning). Wrapping in 'if' exempts the
+ # pipeline from set -e/pipefail so we can read PIPESTATUS[1] (slackpkg's own
+ # status) instead of the SIGPIPE-poisoned pipeline status.
+ local gpg_rc
+ if yes YES | chroot "${ROOTFS}" /usr/sbin/slackpkg -batch=on -default_answer=y update gpg
+ then gpg_rc="${PIPESTATUS[1]}"; else gpg_rc="${PIPESTATUS[1]}"; fi
+ [[ "${gpg_rc}" -eq 0 ]] || _warn "slackpkg GPG update failed; check the output above."
+
+ _log "Updating slackpkg package list..."
+ chroot "${ROOTFS}" /usr/sbin/slackpkg -batch=on -default_answer=y update || _warn "slackpkg update failed; cache will build on first container run."
+
+ # --- Clean up ---
+ _log "Cleaning up rootfs..."
+ rm -f "${ROOTFS}"/boot/*
+ rm -f "${ROOTFS}"/tmp/[A-Za-z]*
+ rm -f "${ROOTFS}"/var/mail/*
+ rm -rf "${ROOTFS}"/dev/*
+ rm -rf "${ROOTFS}"/usr/share/locale/*
+ rm -rf "${ROOTFS}"/usr/info/*
+ rm -rf "${ROOTFS}"/usr/man/*
+ (cd "${ROOTFS}/usr/doc" 2>/dev/null && \
+ find . -type d -mindepth 2 -maxdepth 2 | grep -v '/cups-' | \
+ xargs rm -rf) || true
+ rm -rf "${ROOTFS}"/usr/doc/*/html
+ rm -f "${ROOTFS}"/usr/doc/*/*.{pdf,db,gz,bz2,xz,txt,TXT} 2>/dev/null || true
+ rm -rf "${ROOTFS}"/usr/share/gtk-doc
+ rm -rf "${ROOTFS}"/usr/share/help
+ find "${ROOTFS}"/usr/share/ -type d -name doc | xargs rm -rf
+ # Keep only the terminfo entries the container actually needs
+ find "${ROOTFS}"/usr/share/terminfo/ -type f \
+ ! -name 'linux' ! -name 'xterm' ! -name 'screen.linux' \
+ -delete 2>/dev/null || true
+ find "${ROOTFS}"/usr/share/terminfo/ -xtype l -delete 2>/dev/null || true
+ # gpg-agent socket files left behind by slackpkg in the chroot.
+ # tar cannot archive Unix domain sockets; removing them eliminates
+ # the "socket ignored" warnings when the image tarball is created.
+ rm -f "${ROOTFS}"/root/.gnupg/S.*
+
+ # Kernel-specific content has no meaning in a container
+ rm -rf "${ROOTFS}"/usr/src
+ rm -rf "${ROOTFS}"/lib/modules
+ rm -rf "${ROOTFS}"/lib64/modules 2>/dev/null || true
+
+ # --- Build Docker image ---
+ _log "Creating image tarball..."
+ tar -C "${ROOTFS}" -czf "${WORKDIR}/rootfs.tar.gz" .
+
+ local BUILD_DATE
+ BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+
+ cat > "${WORKDIR}/Dockerfile" <<DOCKERFILE
+FROM scratch
+LABEL maintainer="Eric Hameleers <alien@slackware.com>"
+ADD rootfs.tar.gz /
+ENV container=docker
+ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+LABEL org.opencontainers.image.created="${BUILD_DATE}"
+LABEL org.opencontainers.image.title="Slackware Linux ${VERSION} (x86_64)"
+LABEL org.opencontainers.image.description="Slackware Linux ${VERSION} base container image (x86_64)"
+LABEL org.opencontainers.image.vendor="Slackware Linux"
+LABEL org.opencontainers.image.licenses="GPL-2.0-or-later AND LGPL-2.1-or-later"
+LABEL org.opencontainers.image.url="http://www.slackware.com"
+LABEL org.opencontainers.image.source="https://forge.slackware.nl/slackware/docker-images"
+LABEL org.opencontainers.image.revision="${VERSION}"
+LABEL slackware.version="${VERSION}"
+LABEL slackware.arch="x86_64"
+CMD ["/bin/bash"]
+DOCKERFILE
+
+ # Explicit exit checks: build_variant runs in an `if ! (...)` condition
+ # (see Main), which suppresses `set -e` in this subshell, so a failed build
+ # would otherwise push a nonexistent tag and log "Done".
+ _log "Building and pushing ${TAG}..."
+ if ! docker build --no-cache -t "${TAG}" "${WORKDIR}"; then
+ _warn "build failed for ${TAG}; not pushing."
+ return 1
+ fi
+ if ! docker push "${TAG}"; then
+ _warn "push failed for ${TAG}."
+ return 1
+ fi
+ _log "=== Done: ${TAG} ==="
+}
+
+# ============================================================================
+# Main
+# ============================================================================
+
+rc=0
+for VERSION in "${BUILD_VARIANTS[@]}"; do
+ if changelog_changed "${VERSION}"; then
+ if ! ( build_variant "${VERSION}" ); then
+ _warn "variant ${VERSION} failed; continuing."
+ rc=1
+ fi
+ fi
+done
+exit "${rc}"
diff --git a/image-builder/build-full-image.sh b/image-builder/build-full-image.sh
new file mode 100755
index 0000000..a2fdb18
--- /dev/null
+++ b/image-builder/build-full-image.sh
@@ -0,0 +1,245 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# build-full-image.sh — sbo-full:{ver} FROM sbo-base:{ver}, all series.
+# Adapted from forge slackware/docker-images. No root needed.
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+source "${HERE}/config"
+LOG_TAG=full
+source "${HERE}/lib.sh"
+
+BASE_IMAGE="${REGISTRY}/sbo-base"
+FULL_IMAGE="${REGISTRY}/sbo-full"
+DIGEST_LABEL="sbo.full.base-digest"
+
+# ============================================================================
+# Argument parsing
+# ============================================================================
+
+FORCE=false
+OPT_VERSION=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version) OPT_VERSION="$2"; shift 2 ;;
+ --force) FORCE=true; shift ;;
+ *) _err "unknown argument: $1" ;;
+ esac
+done
+if [[ -n "${OPT_VERSION}" ]]; then
+ BUILD_VARIANTS=("${OPT_VERSION}")
+else
+ BUILD_VARIANTS=("${VARIANTS[@]}")
+fi
+
+# ============================================================================
+# Tag derivation
+# ============================================================================
+
+# derive_tags VERSION
+# Sets globals BASE_TAG and FULL_TAG for the given variant.
+derive_tags() {
+ local VERSION="$1"
+ BASE_TAG="${BASE_IMAGE}:${VERSION}"
+ FULL_TAG="${FULL_IMAGE}:${VERSION}"
+}
+
+# ============================================================================
+# Rebuild detection
+#
+# After pulling the latest base image, its digest is compared with the
+# digest stored in the DIGEST_LABEL on the current full image. If
+# they differ the base image has been updated and the full must be
+# rebuilt to incorporate slackpkg package updates shipped with the new base.
+# ============================================================================
+
+needs_rebuild() {
+ local BASE_TAG="$1" FULL_TAG="$2"
+
+ if [[ "${FORCE}" == "true" ]]; then
+ _log " --force specified; rebuilding unconditionally."
+ return 0
+ fi
+
+ # Digest of the base image we just pulled
+ local BASE_DIGEST
+ BASE_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' \
+ "${BASE_TAG}" 2>/dev/null || echo "")
+ if [[ -z "${BASE_DIGEST}" ]]; then
+ _warn " Could not determine base image digest; rebuilding to be safe."
+ return 0
+ fi
+
+ # Digest recorded in the full image's label (empty if image doesn't exist)
+ local RECORDED_DIGEST
+ RECORDED_DIGEST=$(docker inspect \
+ --format "{{index .Config.Labels \"${DIGEST_LABEL}\"}}" \
+ "${FULL_TAG}" 2>/dev/null || echo "")
+
+ if [[ "${BASE_DIGEST}" == "${RECORDED_DIGEST}" ]]; then
+ _log " Base image unchanged (${BASE_DIGEST:0:40}...); skipping."
+ return 1
+ fi
+
+ _log " Base image changed; rebuilding."
+ _log " Was : ${RECORDED_DIGEST:0:60}..."
+ _log " Now : ${BASE_DIGEST:0:60}..."
+ return 0
+}
+
+# ============================================================================
+# Build one variant
+# ============================================================================
+
+build_variant() {
+ local VERSION="$1"
+ local REPO_KEY="slackware64-${VERSION}"
+ require_mount "${VERSION}" # NFS must be up: we serve it over HTTP below
+ derive_tags "${VERSION}"
+
+ _log "=== ${FULL_TAG} ==="
+
+ # Pull the latest base image so digest comparison is against the current
+ # state of the registry, not a stale local cache.
+ _log " Pulling base image ${BASE_TAG}..."
+ docker pull "${BASE_TAG}"
+
+ if ! needs_rebuild "${BASE_TAG}" "${FULL_TAG}"; then
+ return 0
+ fi
+
+ # Record the base image digest for the label
+ local BASE_DIGEST
+ BASE_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "${BASE_TAG}")
+
+ local WORKDIR
+ WORKDIR="$(mktemp -d /tmp/slackware-full.XXXXXX)"
+
+ # Serve the NFS mirror over HTTP on the docker bridge for the duration of
+ # the build, so slackpkg in the build container fetches packages from LAN
+ # instead of the internet. Killed (with WORKDIR cleanup) on any exit.
+ local MIRROR_ROOT; MIRROR_ROOT="$(strip_scheme "$(mirror_path "${MIRROR}" "${REPO_KEY}")")"
+ [[ -f "${MIRROR_ROOT}/PACKAGES.TXT" ]] \
+ || _err "mirror root has no PACKAGES.TXT: ${MIRROR_ROOT}"
+ python3 -m http.server "${HTTP_MIRROR_PORT}" \
+ --bind "${HTTP_MIRROR_HOST}" --directory "${MIRROR_ROOT}" \
+ >/dev/null 2>&1 &
+ local HTTP_PID=$!
+ # shellcheck disable=SC2064
+ trap "kill ${HTTP_PID} 2>/dev/null; rm -rf '${WORKDIR}'" EXIT
+ # Wait for the server to accept connections before building.
+ local i
+ for i in 1 2 3 4 5 6 7 8 9 10; do
+ curl -sf -o /dev/null "http://${HTTP_MIRROR_HOST}:${HTTP_MIRROR_PORT}/PACKAGES.TXT" && break
+ [[ $i -eq 10 ]] && _err "HTTP mirror did not come up on ${HTTP_MIRROR_HOST}:${HTTP_MIRROR_PORT}"
+ sleep 0.5
+ done
+
+ # Generate the Dockerfile for this variant
+ cat > "${WORKDIR}/Dockerfile" <<DOCKERFILE
+FROM ${BASE_TAG}
+LABEL maintainer="Eric Hameleers <alien@slackware.com>"
+
+# Import the Slackware GPG key against THIS mirror first: without it slackpkg
+# cannot verify CHECKSUMS.md5 and silently installs almost nothing (only the
+# packages already in base). 'yes YES |' answers the import prompt.
+RUN yes YES | LC_ALL=C slackpkg -batch=on -default_answer=y update gpg
+
+# Update the slackpkg package list before installing so that we always
+# get the versions current at build time, not the versions cached in the
+# base image.
+RUN LC_ALL=C slackpkg -batch=on -default_answer=y update
+
+# Download and install each individual package (thanks aclemons):
+RUN sed -i 's/DOWNLOAD_ALL=on/DOWNLOAD_ALL=off/' /etc/slackpkg/slackpkg.conf
+
+# We don't care about a big firmware package:
+RUN mv /etc/slackpkg/blacklist{,.keep} && \\
+ echo kernel-firmware > /etc/slackpkg/blacklist
+
+# Install the full Slackware:
+# All packages are installed in a single RUN layer to keep the image lean.
+RUN export LC_ALL=C && \\
+ for series in a ap d e f k kde l n t tcl x xap xfce y ; do \\
+ slackpkg -delall=on -batch=on -default_answer=y install "\$series"/* ; done
+
+# And back to defaults:
+RUN sed -i 's/DOWNLOAD_ALL=off/DOWNLOAD_ALL=on/' /etc/slackpkg/slackpkg.conf
+RUN mv /etc/slackpkg/blacklist{.keep,}
+
+
+# Delete package cache:
+RUN rm -rf /var/cache/packages/*
+
+# Rebuild the linker cache after adding new libraries
+RUN /sbin/ldconfig
+
+LABEL org.opencontainers.image.created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+LABEL org.opencontainers.image.title="Slackware Linux ${VERSION} full (x86_64)"
+LABEL org.opencontainers.image.description="Slackware Linux ${VERSION} full image with all packages installed (x86_64)"
+LABEL org.opencontainers.image.vendor="Slackware Linux"
+LABEL org.opencontainers.image.licenses="GPL-2.0-or-later AND LGPL-2.1-or-later"
+LABEL org.opencontainers.image.url="http://www.slackware.com"
+LABEL org.opencontainers.image.source="https://forge.slackware.nl/slackware/docker-images"
+LABEL org.opencontainers.image.revision="${VERSION}"
+LABEL slackware.version="${VERSION}"
+LABEL slackware.arch="x86_64"
+LABEL ${DIGEST_LABEL}="${BASE_DIGEST}"
+CMD ["/bin/bash"]
+DOCKERFILE
+
+ local BUILD_FLAGS=()
+ [[ "${FORCE}" == "true" ]] && BUILD_FLAGS+=(--no-cache)
+
+ # Explicit exit checks: build_variant runs in an `if ! (...)` condition
+ # (see Main), which suppresses `set -e` inside this subshell, so a failed
+ # docker build would otherwise fall through to push a nonexistent tag and
+ # log "Done". Check each step and return non-zero on failure.
+ _log " Building ${FULL_TAG}..."
+ if ! docker build "${BUILD_FLAGS[@]}" \
+ -t "${FULL_TAG}" \
+ "${WORKDIR}"; then
+ _warn " build failed for ${FULL_TAG}; not pushing."
+ return 1
+ fi
+
+ _log " Pushing ${FULL_TAG}..."
+ if ! docker push "${FULL_TAG}"; then
+ _warn " push failed for ${FULL_TAG}."
+ return 1
+ fi
+
+ _log "=== Done: ${FULL_TAG} ==="
+}
+
+# ============================================================================
+# Main
+# ============================================================================
+
+# A --force build passes --no-cache, so the stale build cache is dead weight:
+# it still loads the containerd snapshotter's lease/lock bookkeeping and has
+# raced the layer export ("failed to open writer: ref ... locked ... unavailable").
+# Prune it first to shrink that surface. Best-effort; never fail the build on it.
+if [[ "${FORCE}" == "true" ]]; then
+ _log " --force: pruning build cache before rebuild"
+ docker builder prune -f >/dev/null 2>&1 || _warn " builder prune failed (ignored)"
+fi
+
+rc=0
+for VERSION in "${BUILD_VARIANTS[@]}"; do
+ if ! ( build_variant "${VERSION}" ); then
+ _warn "variant ${VERSION} failed; continuing."
+ rc=1
+ fi
+done
+exit "${rc}"
diff --git a/image-builder/build-sbo-testbuild.sh b/image-builder/build-sbo-testbuild.sh
new file mode 100755
index 0000000..a246b79
--- /dev/null
+++ b/image-builder/build-sbo-testbuild.sh
@@ -0,0 +1,143 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# build-sbo-testbuild.sh — sbo-testbuild:{ver} FROM sbo-full:{ver}.
+# Adds sbopkg + sbo-maintainer-tools from prebuilt .txz in PKGDIR.
+# Rebuilds when the full image OR the .txz set changes.
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+source "${HERE}/config"
+LOG_TAG=testbuild
+source "${HERE}/lib.sh"
+
+FULL_IMAGE="${REGISTRY}/sbo-full"
+TB_IMAGE="${REGISTRY}/sbo-testbuild"
+DIGEST_LABEL="sbo.testbuild.full-digest"
+PKGS_LABEL="sbo.testbuild.pkgs-hash"
+
+FORCE=false
+OPT_VERSION=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version) OPT_VERSION="$2"; shift 2 ;;
+ --force) FORCE=true; shift ;;
+ *) _err "unknown argument: $1" ;;
+ esac
+done
+if [[ -n "${OPT_VERSION}" ]]; then
+ BUILD_VARIANTS=("${OPT_VERSION}")
+else
+ BUILD_VARIANTS=("${VARIANTS[@]}")
+fi
+
+# Verify the required packages (*.t?z) are present before doing anything.
+require_pkgs() {
+ [[ -d "${PKGDIR}" ]] || _err "PKGDIR missing: ${PKGDIR}"
+ compgen -G "${PKGDIR}/sbopkg-*.t?z" >/dev/null \
+ || _err "no sbopkg-*.t?z in ${PKGDIR}"
+ compgen -G "${PKGDIR}/sbo-maintainer-tools-*.t?z" >/dev/null \
+ || _err "no sbo-maintainer-tools-*.t?z in ${PKGDIR}"
+}
+
+# needs_rebuild FULL_TAG TB_TAG PKGS_HASH -> 0 if rebuild needed
+needs_rebuild() {
+ local full_tag="$1" tb_tag="$2" pkgs_hash="$3"
+ [[ "${FORCE}" == "true" ]] && { _log " --force."; return 0; }
+
+ local full_digest
+ full_digest=$(docker inspect --format '{{index .RepoDigests 0}}' \
+ "${full_tag}" 2>/dev/null || echo "")
+ [[ -n "${full_digest}" ]] || { _warn " no full digest; rebuilding."; return 0; }
+
+ local rec_digest rec_pkgs
+ rec_digest=$(docker inspect \
+ --format "{{index .Config.Labels \"${DIGEST_LABEL}\"}}" \
+ "${tb_tag}" 2>/dev/null || echo "")
+ rec_pkgs=$(docker inspect \
+ --format "{{index .Config.Labels \"${PKGS_LABEL}\"}}" \
+ "${tb_tag}" 2>/dev/null || echo "")
+
+ if [[ "${full_digest}" == "${rec_digest}" && "${pkgs_hash}" == "${rec_pkgs}" ]]; then
+ _log " full image and .txz set unchanged; skipping."
+ return 1
+ fi
+ _log " full image or .txz set changed; rebuilding."
+ return 0
+}
+
+build_variant() {
+ local VERSION="$1"
+ local FULL_TAG="${FULL_IMAGE}:${VERSION}"
+ local TB_TAG="${TB_IMAGE}:${VERSION}"
+ _log "=== ${TB_TAG} ==="
+
+ _log " Pulling ${FULL_TAG}..."
+ docker pull "${FULL_TAG}"
+
+ local PKGS_HASH; PKGS_HASH="$(txz_hash "${PKGDIR}")"
+
+ if ! needs_rebuild "${FULL_TAG}" "${TB_TAG}" "${PKGS_HASH}"; then
+ return 0
+ fi
+
+ local FULL_DIGEST
+ FULL_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "${FULL_TAG}" 2>/dev/null || echo "")
+
+ local WORKDIR; WORKDIR="$(mktemp -d /tmp/sbo-testbuild.XXXXXX)"
+ # shellcheck disable=SC2064
+ trap "rm -rf '${WORKDIR}'" EXIT
+ mkdir -p "${WORKDIR}/pkgs"
+ cp "${PKGDIR}"/sbopkg-*.t?z "${PKGDIR}"/sbo-maintainer-tools-*.t?z "${WORKDIR}/pkgs/"
+
+ cat > "${WORKDIR}/Dockerfile" <<DOCKERFILE
+FROM ${FULL_TAG}
+LABEL maintainer="danix <danix@danix.xyz>"
+COPY pkgs/ /tmp/pkgs/
+RUN installpkg /tmp/pkgs/* && rm -rf /tmp/pkgs
+RUN /sbin/ldconfig
+LABEL org.opencontainers.image.created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+LABEL org.opencontainers.image.title="SBo test-build env ${VERSION} (x86_64)"
+LABEL org.opencontainers.image.description="Slackware ${VERSION} full + sbopkg + sbo-maintainer-tools"
+LABEL slackware.version="${VERSION}"
+LABEL ${DIGEST_LABEL}="${FULL_DIGEST}"
+LABEL ${PKGS_LABEL}="${PKGS_HASH}"
+CMD ["/bin/bash"]
+DOCKERFILE
+
+ local BUILD_FLAGS=()
+ [[ "${FORCE}" == "true" ]] && BUILD_FLAGS+=(--no-cache)
+ # Explicit exit checks: build_variant runs in an `if ! (...)` condition
+ # (see Main), which suppresses `set -e` in this subshell, so a failed build
+ # would otherwise push a nonexistent tag and log "Done".
+ _log " Building ${TB_TAG}..."
+ if ! docker build "${BUILD_FLAGS[@]}" -t "${TB_TAG}" "${WORKDIR}"; then
+ _warn " build failed for ${TB_TAG}; not pushing."
+ return 1
+ fi
+ _log " Pushing ${TB_TAG}..."
+ if ! docker push "${TB_TAG}"; then
+ _warn " push failed for ${TB_TAG}."
+ return 1
+ fi
+ _log "=== Done: ${TB_TAG} ==="
+}
+
+require_pkgs
+rc=0
+for VERSION in "${BUILD_VARIANTS[@]}"; do
+ if ! ( build_variant "${VERSION}" ); then
+ _warn "variant ${VERSION} failed; continuing."
+ rc=1
+ fi
+done
+exit "${rc}"
diff --git a/image-builder/config b/image-builder/config
new file mode 100644
index 0000000..8b52313
--- /dev/null
+++ b/image-builder/config
@@ -0,0 +1,15 @@
+# Shared configuration for the sbo-testbuild image builder.
+# Sourced by bootstrap.sh, build-full-image.sh, build-sbo-testbuild.sh, tests.
+
+REGISTRY="docker.noland.dnx:5000"
+MIRROR="file:///mnt/nas"
+VARIANTS=(current 15.0) # x86_64 only for now
+PKGDIR="/opt/sbo-testbuild/pkgs" # sbopkg + sbo-maintainer-tools .txz
+HASH_DIR="/var/cache/sbo-testbuild" # ChangeLog hashes for rebuild gating
+
+# During the full-image build, packages are served to the build container off
+# the local NFS mirror (MIRROR) over HTTP on the docker bridge, so slackpkg
+# fetches from LAN instead of the internet. The bridge-gateway IP is what the
+# build container sees as the host; the port is ephemeral to this host.
+HTTP_MIRROR_HOST="172.17.0.1" # docker0 bridge gateway (host from container)
+HTTP_MIRROR_PORT="8099"
diff --git a/image-builder/lib.sh b/image-builder/lib.sh
new file mode 100644
index 0000000..dfb076b
--- /dev/null
+++ b/image-builder/lib.sh
@@ -0,0 +1,84 @@
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# Shared helpers for the image builder. Source, do not execute.
+# Sourcing must have no side effects beyond defining functions.
+
+_log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${LOG_TAG:-image-builder}] $*"; }
+_warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${LOG_TAG:-image-builder}] WARNING: $*" >&2; }
+_err() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${LOG_TAG:-image-builder}] ERROR: $*" >&2; exit 1; }
+
+# mirror_path MIRROR RELPATH
+# Join a MIRROR (file:///... or http(s)://...) with a relative path.
+mirror_path() {
+ local mirror="$1" rel="$2"
+ printf '%s/%s' "${mirror%/}" "${rel#/}"
+}
+
+# is_local_mirror MIRROR -> 0 if file:// or bare absolute path, else 1
+is_local_mirror() {
+ case "$1" in
+ file://*) return 0 ;;
+ http://*|https://*) return 1 ;;
+ /*) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+# strip_scheme URL -> filesystem path for a file:// or bare-local URL
+strip_scheme() {
+ local u="$1"
+ case "$u" in
+ file://*) printf '%s' "${u#file://}" ;;
+ *) printf '%s' "$u" ;;
+ esac
+}
+
+# fetch SRC DEST
+# Copy SRC (a full mirror URL) to DEST. Local mirror -> cp, http -> curl.
+# Returns non-zero on failure; caller decides whether that is fatal.
+fetch() {
+ local src="$1" dest="$2"
+ if is_local_mirror "$src"; then
+ local path; path="$(strip_scheme "$src")"
+ [[ -f "$path" ]] || return 1
+ cp "$path" "$dest"
+ else
+ curl -sSfL --connect-timeout 60 "$src" -o "$dest"
+ fi
+}
+
+# txz_hash DIR
+# Stable hash of the set of Slackware package files (*.t?z) in DIR (names +
+# contents), order
+# independent. Empty/absent dir -> empty string.
+txz_hash() {
+ local dir="$1"
+ [[ -d "$dir" ]] || { printf ''; return 0; }
+ local files; files=$(find "$dir" -maxdepth 1 -name '*.t?z' -type f | sort)
+ [[ -n "$files" ]] || { printf ''; return 0; }
+ # Hash sorted "name sha256" lines so reordering does not change output.
+ while IFS= read -r f; do
+ printf '%s %s\n' "$(basename "$f")" "$(sha256sum "$f" | cut -d' ' -f1)"
+ done <<< "$files" | sha256sum | cut -d' ' -f1
+}
+
+# require_mount VERSION
+# Assert the NAS tree for VERSION is mounted (file:// mirror only).
+# x86_64 tree dir is slackware64-${VERSION}.
+require_mount() {
+ local version="$1"
+ is_local_mirror "$MIRROR" || return 0 # http mirror: nothing to check
+ local base; base="$(strip_scheme "$MIRROR")"
+ local dir="${base}/slackware64-${version}"
+ [[ -d "$dir" ]] || _err "NAS tree not mounted: ${dir}"
+}
diff --git a/image-builder/test-image-builder.sh b/image-builder/test-image-builder.sh
new file mode 100755
index 0000000..525ea8f
--- /dev/null
+++ b/image-builder/test-image-builder.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# Assert-based self-check for lib.sh pure logic. No docker, no network.
+# Run: bash test-image-builder.sh
+set -u
+cd "$(dirname "$0")"
+source ./lib.sh
+
+pass=0 fail=0
+check() { # DESC EXPECTED ACTUAL
+ if [[ "$2" == "$3" ]]; then pass=$((pass+1));
+ else fail=$((fail+1)); echo "FAIL: $1"; echo " expected: [$2]"; echo " actual: [$3]"; fi
+}
+check_rc() { # DESC EXPECTED_RC ACTUAL_RC
+ if [[ "$2" == "$3" ]]; then pass=$((pass+1));
+ else fail=$((fail+1)); echo "FAIL: $1 (rc expected $2 got $3)"; fi
+}
+
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+# --- mirror_path ---
+check "mirror_path trailing slash" \
+ "file:///mnt/nas/slackware64-current/PACKAGES.TXT" \
+ "$(mirror_path 'file:///mnt/nas/' 'slackware64-current/PACKAGES.TXT')"
+check "mirror_path no trailing slash" \
+ "file:///mnt/nas/x/y" "$(mirror_path 'file:///mnt/nas' '/x/y')"
+
+# --- is_local_mirror ---
+is_local_mirror "file:///mnt/nas"; check_rc "is_local file://" 0 $?
+is_local_mirror "/mnt/nas"; check_rc "is_local bare abs" 0 $?
+is_local_mirror "https://x/y"; check_rc "is_local https" 1 $?
+
+# --- strip_scheme ---
+check "strip_scheme file://" "/mnt/nas" "$(strip_scheme 'file:///mnt/nas')"
+check "strip_scheme bare" "/mnt/nas" "$(strip_scheme '/mnt/nas')"
+
+# --- fetch: local file resolves ---
+echo hello > "$tmp/src.txt"
+fetch "file://$tmp/src.txt" "$tmp/dst.txt"; check_rc "fetch local ok" 0 $?
+check "fetch local content" "hello" "$(cat "$tmp/dst.txt" 2>/dev/null)"
+
+# --- fetch: missing local file errors ---
+fetch "file://$tmp/nope.txt" "$tmp/x.txt"; check_rc "fetch local missing" 1 $?
+
+# --- txz_hash: stable + content-sensitive ---
+mkdir -p "$tmp/pkgs"
+printf a > "$tmp/pkgs/sbopkg-1.txz"
+printf b > "$tmp/pkgs/tools-1.txz"
+h1="$(txz_hash "$tmp/pkgs")"
+h2="$(txz_hash "$tmp/pkgs")"
+check "txz_hash stable" "$h1" "$h2"
+printf c > "$tmp/pkgs/tools-1.txz" # change content
+h3="$(txz_hash "$tmp/pkgs")"
+check_rc "txz_hash changes on content" 0 "$([[ "$h1" != "$h3" ]] && echo 0 || echo 1)"
+check "txz_hash empty dir" "" "$(txz_hash "$tmp/empty-nope")"
+
+# --- require_mount: missing mount errors (subshell to catch _err exit) ---
+MIRROR="file://$tmp/no-such-root"
+( require_mount current ) 2>/dev/null; check_rc "require_mount missing" 1 $?
+mkdir -p "$tmp/mnt/slackware64-current"
+MIRROR="file://$tmp/mnt"
+( require_mount current ) 2>/dev/null; check_rc "require_mount present" 0 $?
+MIRROR="https://example/x"
+( require_mount current ) 2>/dev/null; check_rc "require_mount http skips" 0 $?
+
+echo "----"
+echo "PASS: $pass FAIL: $fail"
+[[ "$fail" -eq 0 ]]