diff options
| author | Danilo M. <danix@danix.xyz> | 2026-07-14 16:59:44 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-07-14 16:59:44 +0200 |
| commit | 3cd610c212076d438f2d1103e0fc584a310011d5 (patch) | |
| tree | b82bd71ff139408dc84f685345862f944653966b /image-builder | |
| download | sbo-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/README | 69 | ||||
| -rwxr-xr-x | image-builder/bootstrap.sh | 564 | ||||
| -rwxr-xr-x | image-builder/build-full-image.sh | 245 | ||||
| -rwxr-xr-x | image-builder/build-sbo-testbuild.sh | 143 | ||||
| -rw-r--r-- | image-builder/config | 15 | ||||
| -rw-r--r-- | image-builder/lib.sh | 84 | ||||
| -rwxr-xr-x | image-builder/test-image-builder.sh | 80 |
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 ]] |
