#!/bin/bash # # sbo-batch-test - batch-test SlackBuilds against a clean Slackware 15.0 # overlay chroot. Non-interactive, dependency-resolving, per-target disposable # overlay, persistent logs, color summary. # # Some parts of this script are inspired by overlay-chroot.sh by Slackware user # bassmadrigal (Jeremy Hansen): specifically the overlayfs setup, the system # bind mounts (dev, proc, sys, dev/pts, resolv.conf, dbus machine-id), the base # patching from the local mirror, and the teardown ordering. That teardown order # (pts, dev/proc/sys, resolv.conf, dbus machine-id, overlay last) is preserved # deliberately, it is the correct unwind order. # # Runs INSIDE a Slackware64-current VM. Verifies SlackBuilds BUILD and install # cleanly against 15.0 userland. Does NOT test kernel modules (shares host # kernel). Resolution is LOCAL-tree-only, never network. Does not touch # slackrepo. # # No em dashes in prose by author convention. # ============================================================================= # CONFIG (edit these for your VM) # ============================================================================= # LOCAL (non-NFS) read-only 15.0 base install tree. This is the overlay # lowerdir. MUST be a local filesystem (ext4/xfs). NEVER point this at the NFS # mirror: overlayfs over NFS lowerdir is fragile and fails intermittently. SLACKWARE_BASE="/sbo-base/15.0" # NFS-mounted 15.0 mirror. Package SOURCE only, used to populate/patch # SLACKWARE_BASE. Read-only is fine. Never used as the overlay lowerdir. LOCAL_MIRROR_15="/mnt/nfs/slackware64-15.0" # One or more LOCAL SBo tree roots, resolved in order (first match wins). # Standard SBo layout: ///{prog.SlackBuild,prog.info,...}. # Read in place, never copied or synced. SBO_TREE_ROOTS=( "/home/danix/SBo-danix" "/home/danix/slackbuilds-15.0" ) # Where overlays are created. LOCAL. One disposable overlay per target lives here. CHROOT_LOCATION="/tmp" # Where persistent logs are written (outside the overlay, survives teardown). LOG_ROOT="/var/log/sbo-batch-test" # Slackware version, used for the mirror ChangeLog / patches path. VERSION="15.0" # ============================================================================= # END CONFIG # ============================================================================= set -uo pipefail # Note: NOT using -e globally. One package build failing is an expected, # handled outcome, not a script crash. Per-package execution is isolated. # ---- flags / globals -------------------------------------------------------- USE_COLOR=1 # --no-color or non-TTY disables DRY_RUN=0 # resolve + print build order, do not build WITH_X=0 # --with-x: optional X passthrough (default headless) JOBS=1 # -j: reserved, see TODO TARGET_ARG="" RUN_DIR="" # timestamped log dir for this run declare -a ACTIVE_MOUNTS=() # overlay chroot roots currently mounted (for trap unwind) CURRENT_OVERLAY="" # the $TMPDIR of the overlay being built in now # Status tracking. Keyed by "category/prog". Parallel assoc arrays. declare -A ST_STATUS=() # SUCCESS|BUILD-FAILED|DOWNLOAD-FAILED|MD5-MISMATCH|INSTALL-FAILED|BLOCKED-BY-DEP|UNMET-DEP declare -A ST_REASON=() declare -A ST_TIME=() # elapsed seconds declare -A ST_README=() # 1 if package carries %README% # ============================================================================= # usage # ============================================================================= usage() { cat <<'EOF' sbo-batch-test - batch-test SlackBuilds against a clean Slackware 15.0 overlay chroot USAGE: sbo-batch-test [OPTIONS] sbo-batch-test [OPTIONS] MODES: Resolve its full SBo dependency tree, build+install every dep in topological order, then build+install the target. Treat every SlackBuild dir inside as an independent target. Each target gets its OWN fresh overlay against pristine 15.0. OPTIONS: -h, --help This text. --no-color Disable ANSI color (auto-disabled when stdout is not a TTY). --dry-run Resolve and print the build order, do not build. --with-x Enable X passthrough via xhost +local:hosts (security caveat: allows local non-network connections to your X server). Default is headless (no X). -j, --jobs N Reserved. Currently a no-op stub (builds are serial). EXTENSION POINTS (not implemented, see TODOs in source): - "all" mode (build every package across all SBo roots). - queue/list-file mode (build a named list of targets). - -j parallelism. EOF } # ============================================================================= # color helpers # ============================================================================= init_color() { if [[ $USE_COLOR -eq 1 && -t 1 ]]; then C_RED=$'\e[31m'; C_GRN=$'\e[32m'; C_YEL=$'\e[33m'; C_RST=$'\e[0m' else C_RED=""; C_GRN=""; C_YEL=""; C_RST="" fi } # ============================================================================= # arg parsing # ============================================================================= parse_args() { while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage; exit 0 ;; --no-color) USE_COLOR=0; shift ;; --dry-run) DRY_RUN=1; shift ;; --with-x) WITH_X=1; shift ;; -j|--jobs) JOBS="${2:-1}"; shift 2 ;; # TODO: implement parallelism -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; *) if [[ -n "$TARGET_ARG" ]]; then echo "Only one target argument accepted (got '$TARGET_ARG' and '$1')." >&2 exit 2 fi TARGET_ARG="$1"; shift ;; esac done if [[ -z "$TARGET_ARG" ]]; then echo "No target given." >&2; usage >&2; exit 2 fi } # ============================================================================= # startup validation (fail fast, copy-pasteable hints) # ============================================================================= validate_env() { if [[ $EUID -ne 0 ]]; then echo "This tool must run as root (overlay + chroot)." >&2 exit 1 fi # SLACKWARE_BASE: local, looks like a real Slackware install. if [[ ! -d "$SLACKWARE_BASE" || ! -d "$SLACKWARE_BASE/var/log/packages" ]]; then cat >&2 <&2 exit 1 ;; esac # LOCAL_MIRROR_15: NFS, must be mounted/reachable. if [[ ! -d "$LOCAL_MIRROR_15" || ! -f "$LOCAL_MIRROR_15/ChangeLog.txt" ]]; then echo "LOCAL_MIRROR_15 not reachable (NFS mount absent?): $LOCAL_MIRROR_15" >&2 echo "Expected a Slackware 15.0 mirror with ChangeLog.txt at its root." >&2 exit 1 fi # SBo roots exist locally. local r found=0 for r in "${SBO_TREE_ROOTS[@]}"; do if [[ -d "$r" ]]; then found=1; else echo "SBO_TREE_ROOTS path does not exist: $r" >&2 fi done if [[ $found -eq 0 ]]; then echo "No valid SBo tree root found. Fix SBO_TREE_ROOTS." >&2 exit 1 fi } # ============================================================================= # keep base patched from the mirror (reuse reference logic, point at NFS mirror, # write to local base). Skipped on --dry-run. # ============================================================================= update_base() { local marker="$SLACKWARE_BASE/last-base-update" touch "$marker" local head_now; head_now="$(head -n1 "$LOCAL_MIRROR_15/ChangeLog.txt")" if [[ "$head_now" == "$(cat "$marker")" ]]; then echo "Base is up-to-date with the mirror." return fi echo "Patching base from mirror..." local p for p in "$LOCAL_MIRROR_15"/patches/packages/*.t?z; do [[ -e "$p" ]] || continue if [[ ! -e "$SLACKWARE_BASE/var/lib/pkgtools/packages/$(basename "${p%.*}")" ]]; then ROOT="$SLACKWARE_BASE" upgradepkg --install-new "$p" fi done echo "$head_now" > "$marker" echo "Base patched." } # ============================================================================= # SBo tree lookup. Find the SlackBuild dir for a prog name across roots. # Echoes the dir path, returns 0 if found, 1 otherwise. # ============================================================================= find_slackbuild_dir() { local prog="$1" root d for root in "${SBO_TREE_ROOTS[@]}"; do [[ -d "$root" ]] || continue # // for d in "$root"/*/"$prog"; do if [[ -d "$d" && -f "$d/$prog.info" ]]; then echo "$d"; return 0 fi done done return 1 } # Category of a SlackBuild dir = its parent dir name. category_of() { basename "$(dirname "$1")"; } # Key used in status maps and log filenames. pkg_key() { echo "$(category_of "$1")/$(basename "$1")"; } # Read REQUIRES from a .info, stripped. Echoes space-separated tokens. read_requires() { local info="$1" # shellcheck disable=SC1090 ( set +u; source "$info"; echo "${REQUIRES:-}" ) } # ============================================================================= # DEPENDENCY RESOLUTION # Builds a topological order over the local SBo tree. # - transitive REQUIRES # - %README% recorded, not built # - deps already in base (installed in chroot base) are satisfied, not built # - deps neither in SBo tree nor installed => UNMET-DEP # - cycles detected and reported, no infinite loop # # Outputs the order into the global array RESOLVED_ORDER (dir paths). # Records unmet deps into UNMET (prog -> requiring pkg) and cycle errors. # Returns 1 if any hard resolution failure (unmet dep, cycle) for the target set. # ============================================================================= declare -a RESOLVED_ORDER=() declare -A UNMET=() # prog -> "needed by X" declare -a CYCLES=() # human-readable cycle descriptions declare -A HAS_README=() # dir -> 1 if its REQUIRES carried %README% # Is a prog already installed in the base tree? (stock 15.0 or otherwise present) installed_in_base() { local prog="$1" # Package db entries look like prog-version-arch-build[ tag] ls "$SLACKWARE_BASE"/var/log/packages/ 2>/dev/null \ | grep -qE "^${prog}-[^-]+-[^-]+-[^-]+" } # DFS topo sort with cycle detection. # visit_state: dir -> 0 visiting (on stack), 1 done declare -A _vstate=() _resolve_visit() { local dir="$1" parent="$2" local key; key="$(basename "$dir")" if [[ "${_vstate[$dir]:-}" == "1" ]]; then return 0; fi if [[ "${_vstate[$dir]:-}" == "0" ]]; then CYCLES+=("cycle involving $key (pulled in via $parent)") return 1 fi _vstate["$dir"]=0 local info="$dir/$(basename "$dir").info" local req tok depdir rc=0 req="$(read_requires "$info")" for tok in $req; do if [[ "$tok" == "%README%" ]]; then HAS_README["$dir"]=1 continue fi if depdir="$(find_slackbuild_dir "$tok")"; then _resolve_visit "$depdir" "$key" || rc=1 elif installed_in_base "$tok"; then : # satisfied by base, nothing to build else UNMET["$tok"]="needed by $key" rc=1 fi done _vstate["$dir"]=1 RESOLVED_ORDER+=("$dir") return $rc } # Resolve a single target dir into RESOLVED_ORDER (deps first, target last). resolve_target() { local dir="$1" RESOLVED_ORDER=() CYCLES=() UNMET=() _vstate=() _resolve_visit "$dir" "(top)" } # ============================================================================= # OVERLAY LIFECYCLE (per target). Patterns from overlay-chroot.sh. # setup_overlay -> echoes the chroot root path, sets CURRENT_OVERLAY. # teardown_overlay -> idempotent unwind in correct reverse order. # ============================================================================= setup_overlay() { local tmpdir; tmpdir="$(mktemp -d "$CHROOT_LOCATION"/sbo-bt.XXXXXX)" mkdir "$tmpdir"/{changes,tmp,chroot} # overlayfs: read-only 15.0 base as lowerdir, disposable upper on top. mount -t overlay overlay \ -olowerdir="$SLACKWARE_BASE",upperdir="$tmpdir/changes",workdir="$tmpdir/tmp" \ "$tmpdir/chroot" # bind system dirs mkdir -p "$tmpdir"/changes/{dev,proc,sys} local i for i in dev proc sys; do mount -o bind "/$i" "$tmpdir/chroot/$i" done # /dev/pts (sudo/pty) mkdir -p "$tmpdir"/changes/dev/pts mount -o bind /dev/pts "$tmpdir/chroot/dev/pts" # internet mount -o bind /etc/resolv.conf "$tmpdir/chroot/etc/resolv.conf" chroot "$tmpdir/chroot" /bin/bash -c "/usr/sbin/update-ca-certificates --fresh >/dev/null 2>&1" || true # dbus machine-id touch "$tmpdir/chroot/var/lib/dbus/machine-id" mount -o bind /var/lib/dbus/machine-id "$tmpdir/chroot/var/lib/dbus/machine-id" CURRENT_OVERLAY="$tmpdir" ACTIVE_MOUNTS+=("$tmpdir") echo "$tmpdir" } # Idempotent teardown. Safe to call twice, safe mid-abort. teardown_overlay() { local tmpdir="$1" [[ -n "$tmpdir" && -d "$tmpdir" ]] || return 0 local c="$tmpdir/chroot" mountpoint -q "$c/dev/pts" && umount "$c/dev/pts" local i for i in dev proc sys; do mountpoint -q "$c/$i" && umount "$c/$i" done mountpoint -q "$c/etc/resolv.conf" && umount "$c/etc/resolv.conf" mountpoint -q "$c/var/lib/dbus/machine-id" && umount "$c/var/lib/dbus/machine-id" mountpoint -q "$c" && umount "$c" # Remove from ACTIVE_MOUNTS local n=() m for m in "${ACTIVE_MOUNTS[@]}"; do [[ "$m" != "$tmpdir" ]] && n+=("$m"); done ACTIVE_MOUNTS=("${n[@]:-}") rm -rf "$tmpdir" } # Trap: unwind every live overlay on abort. Reverse order of creation. cleanup_trap() { [[ $WITH_X -eq 1 ]] && xhost -local:hosts >/dev/null 2>&1 || true local i for (( i=${#ACTIVE_MOUNTS[@]}-1; i>=0; i-- )); do teardown_overlay "${ACTIVE_MOUNTS[$i]}" done } trap cleanup_trap EXIT INT TERM # ============================================================================= # BUILD + INSTALL one package inside an existing overlay chroot. # Args: # Sets ST_STATUS / ST_REASON / ST_TIME for the package key. # Returns 0 on SUCCESS, 1 otherwise. # ============================================================================= build_one() { local tmpdir="$1" dir="$2" local c="$tmpdir/chroot" local prog cat key prog="$(basename "$dir")"; cat="$(category_of "$dir")"; key="$cat/$prog" local logf="$RUN_DIR/${cat}_${prog}.log" local start; start=$(date +%s) # 1. copy SlackBuild dir into the overlay local workroot="/sbo-work" mkdir -p "$c$workroot" cp -a "$dir" "$c$workroot/$prog" # %README% reminder carries through to summary [[ "${HAS_README[$dir]:-}" == "1" ]] && ST_README["$key"]=1 # 2-5. download, md5, build, install all run INSIDE the chroot non-interactively. # The heredoc script writes a status token to a known file we read back out. # overlayfs note: if a build fails ONLY here and works on bare 15.0, suspect # an overlayfs sharp edge (rename/whiteout quirks) rather than a real build bug. local statf="$c$workroot/$prog.status" chroot "$c" /bin/bash -s <>"$logf" 2>&1 set -uo pipefail cd "$workroot/$prog" || { echo "BUILD-FAILED: cannot cd"; echo BUILD-FAILED > "$workroot/$prog.status"; exit 1; } . ./$prog.info # Log resolved build context so the overlay never needs to be kept. echo "===== sbo-batch-test: $prog =====" echo "PRGNAM=\${PRGNAM:-$prog} VERSION=\${VERSION:-?} BUILD=\${BUILD:-?} TAG=\${TAG:-?}" echo "uname -m: \$(uname -m) OUTPUT=\${OUTPUT:-/tmp}" echo "REQUIRES=\${REQUIRES:-}" echo "=================================" # pick arch-specific download/md5 if present (x86_64 VM) if [ "\$(uname -m)" = "x86_64" ] && [ -n "\${DOWNLOAD_x86_64:-}" ] && [ "\${DOWNLOAD_x86_64}" != "UNSUPPORTED" ] && [ "\${DOWNLOAD_x86_64}" != "UNTESTED" ]; then DL="\$DOWNLOAD_x86_64"; MD="\$MD5SUM_x86_64" else DL="\$DOWNLOAD"; MD="\$MD5SUM" fi # 2. download for u in \$DL; do wget -c --tries=3 "\$u" || { echo DOWNLOAD-FAILED > "$workroot/$prog.status"; exit 1; } done # 3. verify md5 set -- \$MD for u in \$DL; do f="\$(basename "\$u")" want="\$1"; shift got="\$(md5sum "\$f" | cut -d' ' -f1)" if [ "\$got" != "\$want" ]; then echo "MD5 mismatch on \$f: want \$want got \$got" echo MD5-MISMATCH > "$workroot/$prog.status"; exit 1 fi done # 4. build non-interactively chmod +x ./$prog.SlackBuild if ! ./$prog.SlackBuild; then echo BUILD-FAILED > "$workroot/$prog.status"; exit 1 fi # 5. installpkg the resulting package (SlackBuilds write to \${OUTPUT:-/tmp}) out="\${OUTPUT:-/tmp}" pkg="\$(ls -t "\$out"/${prog}-*.t?z 2>/dev/null | head -n1)" if [ -z "\$pkg" ]; then echo "No package produced in \$out" echo BUILD-FAILED > "$workroot/$prog.status"; exit 1 fi if ! installpkg "\$pkg"; then echo INSTALL-FAILED > "$workroot/$prog.status"; exit 1 fi # Log the installed file list (from the package db) so the overlay is disposable. echo "===== installed files: \$(basename "\$pkg") =====" pkgname="\$(basename "\$pkg")"; pkgname="\${pkgname%.t?z}" cat "/var/log/packages/\$pkgname" 2>/dev/null || echo "(package db entry not found: \$pkgname)" echo "=================================" echo SUCCESS > "$workroot/$prog.status" CHROOT_EOF local status="BUILD-FAILED" [[ -f "$statf" ]] && status="$(cat "$statf")" local end; end=$(date +%s) ST_TIME["$key"]=$(( end - start )) ST_STATUS["$key"]="$status" [[ "$status" == "SUCCESS" ]] && return 0 ST_REASON["$key"]="see $(basename "$logf")" return 1 } # ============================================================================= # Run one target: fresh overlay, build its resolved chain, teardown. # Args: # ============================================================================= run_target() { local target_dir="$1" local tkey; tkey="$(pkg_key "$target_dir")" echo echo "=== Target: $tkey ===" resolve_target "$target_dir" local resolve_rc=$? # Hard resolution failures: mark target and (newly seen) deps, skip building. if [[ ${#CYCLES[@]} -gt 0 || ${#UNMET[@]} -gt 0 ]]; then local why="" if [[ ${#UNMET[@]} -gt 0 ]]; then local u for u in "${!UNMET[@]}"; do why+="unmet:$u(${UNMET[$u]}) "; done fi [[ ${#CYCLES[@]} -gt 0 ]] && why+="${CYCLES[*]}" ST_STATUS["$tkey"]="UNMET-DEP" ST_REASON["$tkey"]="$why" echo " resolution failed: $why" return fi if [[ $DRY_RUN -eq 1 ]]; then echo " build order:" local d for d in "${RESOLVED_ORDER[@]}"; do echo " $(pkg_key "$d")$([[ "${HAS_README[$d]:-}" == 1 ]] && echo " [%README%]")" echo "$(pkg_key "$d")" >> "$RUN_DIR/build-order.txt" done return fi echo " build order: ${RESOLVED_ORDER[*]##*/}" for d in "${RESOLVED_ORDER[@]}"; do echo "$(pkg_key "$d")" >> "$RUN_DIR/build-order.txt"; done local tmpdir; tmpdir="$(setup_overlay)" # Build chain in order. On a failure, mark dependents BLOCKED-BY-DEP. local d failed_progs=() for d in "${RESOLVED_ORDER[@]}"; do local key; key="$(pkg_key "$d")" local prog; prog="$(basename "$d")" # already blocked because an earlier dep it needs failed? if depends_on_failed "$d" failed_progs; then ST_STATUS["$key"]="BLOCKED-BY-DEP" ST_REASON["$key"]="blocked by failed dep" [[ "${HAS_README[$d]:-}" == "1" ]] && ST_README["$key"]=1 echo " $key: BLOCKED-BY-DEP" failed_progs+=("$prog") continue fi echo " building $key ..." if build_one "$tmpdir" "$d"; then echo " $key: ${ST_STATUS[$key]} (${ST_TIME[$key]}s)" else echo " $key: ${ST_STATUS[$key]} (${ST_TIME[$key]}s)" failed_progs+=("$prog") fi done # Overlay is always torn down: the per-package logs capture everything worth # inspecting (full build/install output, resolved .info env, installed file # list), so there is no reason to retain the filesystem. teardown_overlay "$tmpdir" } # Does SlackBuild dir $1 directly require any prog in failed list (nameref $2)? # ponytail: direct REQUIRES check only; transitive blocking still works because # this runs in topo order, so a dep's failure propagates up one hop at a time. depends_on_failed() { local dir="$1"; local -n failed="$2" local info="$dir/$(basename "$dir").info" local req tok f req="$(read_requires "$info")" for tok in $req; do [[ "$tok" == "%README%" ]] && continue for f in "${failed[@]:-}"; do [[ "$tok" == "$f" ]] && return 0 done done return 1 } # ============================================================================= # SUMMARY (screen color + plain summary.log) # ============================================================================= print_summary() { local total=$SECONDS local succ=0 fail=0 blocked=0 local summary="$RUN_DIR/summary.log" { echo "sbo-batch-test run summary" echo "target: $TARGET_ARG" echo } > "$summary" echo echo "================ SUMMARY ================" local key for key in "${!ST_STATUS[@]}"; do local st="${ST_STATUS[$key]}" rsn="${ST_REASON[$key]:-}" t="${ST_TIME[$key]:-0}" local rd=""; [[ "${ST_README[$key]:-}" == "1" ]] && rd=" [%README%]" local col="$C_YEL" case "$st" in SUCCESS) col="$C_GRN"; ((succ++)) ;; BLOCKED-BY-DEP|UNMET-DEP) col="$C_YEL"; ((blocked++)) ;; *) col="$C_RED"; ((fail++)) ;; esac printf "%s%-30s %-16s%s %s%s (%ss)\n" "$col" "$key" "$st" "$C_RST" "$rsn" "$rd" "$t" printf "%-30s %-16s %s%s (%ss)\n" "$key" "$st" "$rsn" "$rd" "$t" >> "$summary" done echo "----------------------------------------" printf "%s%d succeeded%s, %s%d failed%s, %s%d blocked%s, total %ss\n" \ "$C_GRN" "$succ" "$C_RST" "$C_RED" "$fail" "$C_RST" "$C_YEL" "$blocked" "$C_RST" "$total" echo "logs: $RUN_DIR" { echo echo "$succ succeeded, $fail failed, $blocked blocked, total ${total}s" echo "logs: $RUN_DIR" } >> "$summary" } # ============================================================================= # main # ============================================================================= main() { parse_args "$@" init_color validate_env RUN_DIR="$LOG_ROOT/$(date +%Y-%m-%d_%H-%M-%S)" mkdir -p "$RUN_DIR" : > "$RUN_DIR/build-order.txt" [[ $DRY_RUN -eq 0 ]] && update_base [[ $WITH_X -eq 1 ]] && { echo "X passthrough on (xhost +local:hosts). Security: allows local connections to your X server."; xhost +local:hosts >/dev/null; } # Collect targets. local -a targets=() if [[ -d "$TARGET_ARG" ]]; then # category-folder mode: every subdir with a matching .info is a target local d prog for d in "$TARGET_ARG"/*; do [[ -d "$d" ]] || continue prog="$(basename "$d")" [[ -f "$d/$prog.info" ]] && targets+=("$d") done if [[ ${#targets[@]} -eq 0 ]]; then echo "No SlackBuild targets found in folder: $TARGET_ARG" >&2 exit 1 fi else # single-package mode: resolve the name in the tree local tdir if tdir="$(find_slackbuild_dir "$TARGET_ARG")"; then targets+=("$tdir") else echo "Program not found in any SBo tree root: $TARGET_ARG" >&2 exit 1 fi fi # TODO: "all" mode and queue/list-file mode plug in here (populate `targets`). local t for t in "${targets[@]}"; do run_target "$t" done print_summary } main "$@"