#!/bin/bash # # Copyright (C) 2026 Danilo M. # # 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 PROJECT_VERSION="1.0.0" # bump via sed across all scripts; see CLAUDE.md Releases HERE="$(cd "$(dirname "$0")" && pwd)" source "${HERE}/config" LOG_TAG=bootstrap source "${HERE}/lib.sh" # -V before the root check so version is queryable as any user. [[ "${1:-}" == "-V" ]] && { echo "bootstrap.sh $PROJECT_VERSION"; exit 0; } 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" <