#!/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 # ============================================================================= # # Do NOT edit values here. Real config lives in an external file, sourced below. # Default path: ~/.config/sbo-batch-tester/config (override with $SBO_BATCH_CONFIG). # Copy config.example from the repo and edit it. Runs as root, so ~ is root's # home (/root/.config/sbo-batch-tester/config). # # Config file sets these (see config.example for docs on each): # SLACKWARE_BASE LOCAL_MIRROR_15 SBO_TREE_ROOTS CHROOT_LOCATION LOG_ROOT VERSION # Defaults. The config file overrides any it sets. These are placeholders, a run # fails fast in validate_env / init_base if the config has not set real paths. SLACKWARE_BASE="" LOCAL_MIRROR_15="" SBO_TREE_ROOTS=() CHROOT_LOCATION="/tmp" LOG_ROOT="/var/log/sbo-batch-test" # Persistent package cache (local disk). Built dependency packages are stored # here and reused across runs while their version is unchanged. Empty disables # the cache (every package builds fresh). Wiped automatically when the base is # patched. Layout mirrors the SBo tree: //--...txz PKG_CACHE="" VERSION="15.0" # rsync source for the SBo tree (same source sbopkg uses). --setup-repo rsyncs # it fresh; --update rsyncs with --delete (personal subtrees excluded) to # unshadow. Empty defaults; real value in the external config. SBO_RSYNC_URL="" # Personal SBo subtrees: dir name (under SBO_TREE_ROOTS[0]) -> git clone URL. # declare -A here so the external config's assignment lands as a global assoc # array. --setup-repo clones each; --update git-pulls each; both then shadow. declare -A SBO_PERSONAL_TREES=() # Source the external config if present. Not an error here if missing (keeps the # script sourceable by test-logic.sh); validate_env / init_base report it. SBO_BATCH_CONFIG="${SBO_BATCH_CONFIG:-$HOME/.config/sbo-batch-tester/config}" if [[ -f "$SBO_BATCH_CONFIG" ]]; then # shellcheck disable=SC1090 source "$SBO_BATCH_CONFIG" fi # Derived: the actual Slackware tree under the mirror mountpoint. Holds # ChangeLog.txt, slackware64/, and patches/. Set after sourcing so it picks up # the config's LOCAL_MIRROR_15 and VERSION. MIRROR_TREE="$LOCAL_MIRROR_15/slackware64-$VERSION" # ============================================================================= # 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 INIT_BASE=0 # --init-base: populate SLACKWARE_BASE from the mirror, then exit SETUP_REPO=0 # --setup-repo: rm+rsync tree, clone subtrees, shadow, then exit DO_UPDATE=0 # --update: rsync-unshadow + pull subtrees + shadow before build 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). --init-base First-time populate SLACKWARE_BASE with the FULL 15.0 package set from the mirror, then exit. Run once before the first build. Refuses to clobber an existing base. --setup-repo First-time build of the SBo tree: REMOVE SBO_TREE_ROOTS[0], rsync it fresh from SBO_RSYNC_URL, clone the SBO_PERSONAL_TREES subtrees in, shadow duplicated upstream packages, then exit. Does not build. Runs as root. --update Before building: rsync the tree (--delete, personal subtrees preserved) to unshadow and refresh upstream, git-pull each personal subtree, re-shadow duplicated upstream packages. 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 --init-base) INIT_BASE=1; shift ;; --setup-repo) SETUP_REPO=1; shift ;; --update) DO_UPDATE=1; shift ;; -*) 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" && $INIT_BASE -eq 0 && $SETUP_REPO -eq 0 ]]; then echo "No target given." >&2; usage >&2; exit 2 fi } # ============================================================================= # startup validation (fail fast, copy-pasteable hints) # ============================================================================= # Shared: the config file must exist and set the required paths. Called by both # validate_env and init_base. require_config() { if [[ ! -f "$SBO_BATCH_CONFIG" ]]; then cat >&2 <&2 exit 1 fi } validate_env() { if [[ $EUID -ne 0 ]]; then echo "This tool must run as root (overlay + chroot)." >&2 exit 1 fi require_config # 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, fstab entry is noauto. Auto-mount if not mounted # (needs the fstab entry for bare `mount `). Left mounted after the run. if ! mountpoint -q "$LOCAL_MIRROR_15"; then echo "LOCAL_MIRROR_15 not mounted, attempting: mount $LOCAL_MIRROR_15" >&2 if ! mount "$LOCAL_MIRROR_15"; then echo "Could not mount $LOCAL_MIRROR_15 (no fstab entry? mount it manually)." >&2 exit 1 fi fi if [[ ! -f "$MIRROR_TREE/ChangeLog.txt" ]]; then echo "Mirror mounted but no ChangeLog.txt under: $MIRROR_TREE" >&2 echo "Expected the Slackware 15.0 tree at \$LOCAL_MIRROR_15/slackware64-\$VERSION." >&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. # ============================================================================= # ============================================================================= # first-time base populate. --init-base only. Installs the FULL 15.0 package # set from the mirror into SLACKWARE_BASE, then exits (caller does not build). # Refuses to clobber an already-populated base. # ============================================================================= init_base() { if [[ $EUID -ne 0 ]]; then echo "--init-base must run as root (installpkg --root)." >&2; exit 1 fi require_config case "$SLACKWARE_BASE" in "$LOCAL_MIRROR_15"*) echo "SLACKWARE_BASE must NOT live under LOCAL_MIRROR_15 (NFS)." >&2; exit 1 ;; esac if ! mountpoint -q "$LOCAL_MIRROR_15"; then echo "LOCAL_MIRROR_15 not mounted, attempting: mount $LOCAL_MIRROR_15" >&2 if ! mount "$LOCAL_MIRROR_15"; then echo "Could not mount $LOCAL_MIRROR_15 (no fstab entry? mount it manually)." >&2; exit 1 fi fi if [[ ! -f "$MIRROR_TREE/ChangeLog.txt" ]]; then echo "Mirror mounted but no ChangeLog.txt under: $MIRROR_TREE" >&2; exit 1 fi if [[ -d "$SLACKWARE_BASE/var/log/packages" ]]; then echo "Base already populated: $SLACKWARE_BASE" >&2 echo "Remove it first to re-init, or just run a build (update_base keeps it patched)." >&2 exit 1 fi echo "Populating base from mirror into: $SLACKWARE_BASE" mkdir -p "$SLACKWARE_BASE" local p n=0 for p in "$MIRROR_TREE"/slackware64/*/*.t?z; do [[ -e "$p" ]] || continue installpkg --terse --root "$SLACKWARE_BASE" "$p" ((n++)); ((n % 50 == 0)) && echo " ...$n packages" done if [[ $n -eq 0 ]]; then echo "No packages found under $MIRROR_TREE/slackware64/*/. Wrong mirror layout?" >&2 exit 1 fi # Seed the update marker so the next run does not re-patch needlessly. head -n1 "$MIRROR_TREE/ChangeLog.txt" > "$SLACKWARE_BASE/last-base-update" echo "Base populated: $n packages installed into $SLACKWARE_BASE" } update_base() { local marker="$SLACKWARE_BASE/last-base-update" touch "$marker" local head_now; head_now="$(head -n1 "$MIRROR_TREE/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 "$MIRROR_TREE"/patches/packages/*.t?z; do [[ -e "$p" ]] || continue if [[ ! -e "$SLACKWARE_BASE/var/lib/pkgtools/packages/$(basename "${p%.*}")" ]]; then ROOT="$SLACKWARE_BASE" upgradepkg --terse --install-new "$p" fi done echo "$head_now" > "$marker" # Base changed: cached deps were built against the old base, drop them all. if [[ -n "$PKG_CACHE" && -d "$PKG_CACHE" ]]; then rm -rf "${PKG_CACHE:?}"/* echo "Package cache cleared (base changed)." fi echo "Base patched." } # Echo one anchored `--exclude=//` per SBO_PERSONAL_TREES key, one per # line. Anchored to the tree root so rsync --delete never wipes a personal # clone (they are not on the rsync source). Empty when no personal trees. rsync_excludes() { local dir for dir in "${!SBO_PERSONAL_TREES[@]}"; do echo "--exclude=/$dir/" done } # rsync the SBo tree from SBO_RSYNC_URL into , excluding every personal # subtree from transfer AND deletion. Flags match sbopkg's own rsync (files # root:root). Returns rsync's exit status; caller decides hard-fail vs warn. rsync_tree() { local root="$1" local excludes=() mapfile -t excludes < <(rsync_excludes) rsync -a --delete --no-owner --no-group \ "${excludes[@]}" \ "$SBO_RSYNC_URL/" "$root/" } # Echo (one per line) the -relative dirs of upstream packages that # duplicate a package in a personal subtree. Reads only, never removes. A # personal package is a subdir with a matching .SlackBuild; an upstream # dup is a same-named depth-2 dir anywhere under outside the personal # trees. collect_shadows() { local root="$1" local -a personal_dirs=() prune=() local dir for dir in "${!SBO_PERSONAL_TREES[@]}"; do [[ -d "$root/$dir" ]] || continue personal_dirs+=("$root/$dir") prune+=(! -path "$root/$dir/*") done [[ ${#personal_dirs[@]} -eq 0 ]] && return local pkgdir pkg match while IFS= read -r pkgdir; do pkg=$(basename "$pkgdir") [[ -f "$pkgdir/$pkg.SlackBuild" ]] || continue while IFS= read -r match; do echo "${match#"$root"/}" done < <(find "$root" -mindepth 2 -maxdepth 2 -type d \ -name "$pkg" "${prune[@]}") done < <(find "${personal_dirs[@]}" -mindepth 1 -maxdepth 1 -type d) } # Show the upstream dups of personal packages, confirm, and rm -r them. Reads # the set from collect_shadows. Interactive by design (the user wants to see # removals before they happen). No git (the 15.0 tree is rsync'd, not git). shadow_scan() { local root="$1" local -a to_shadow=() mapfile -t to_shadow < <(collect_shadows "$root") if [[ ${#to_shadow[@]} -eq 0 ]]; then echo "No upstream packages match your personal subtrees. Nothing to shadow." return fi echo echo "The following upstream packages will be shadowed (rm -r):" local item for item in "${to_shadow[@]}"; do echo " - $item" done echo read -rp "Proceed? [y/N] " confirm if [[ ! "$confirm" =~ ^[Yy]$ ]]; then echo "Aborted shadowing; no packages were removed." return fi for item in "${to_shadow[@]}"; do echo "Shadowing: $item" rm -r "${root:?}/$item" done } # --setup-repo: first-time full build of the SBo tree. rm -rf the tree root, # rsync it fresh, clone the personal subtrees in, shadow upstream dups. Runs its # own checks (the tree may not exist yet). Standalone: exits, never builds. setup_repo() { [[ $EUID -eq 0 ]] || { echo "--setup-repo must run as root (rsync/rm to the tree)." >&2; exit 1; } require_config [[ -n "$SBO_RSYNC_URL" ]] || { echo "SBO_RSYNC_URL is unset; set it in $SBO_BATCH_CONFIG." >&2; exit 1; } local root="${SBO_TREE_ROOTS[0]}" [[ -n "$root" ]] || { echo "SBO_TREE_ROOTS[0] is empty." >&2; exit 1; } echo "This will REMOVE $root and rsync it fresh from $SBO_RSYNC_URL." read -rp "Proceed? [y/N] " confirm [[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } rm -rf "${root:?}" echo "Rsyncing tree..." rsync_tree "$root" || { echo "rsync failed." >&2; exit 1; } local dir for dir in "${!SBO_PERSONAL_TREES[@]}"; do echo "Cloning $dir..." git clone "${SBO_PERSONAL_TREES[$dir]}" "$root/$dir" \ || { echo "clone of $dir failed." >&2; exit 1; } done shadow_scan "$root" echo "Repository ready at $root" exit 0 } # --update: per-run refresh. rsync the tree (--delete, personal excluded) to # unshadow + refresh upstream, git-pull each personal subtree, re-shadow. Inline # and fail-soft: a network hiccup warns but never kills the build session. update_personal_trees() { local root="${SBO_TREE_ROOTS[0]}" [[ -n "$root" ]] || { echo "SBO_TREE_ROOTS[0] is empty; skipping --update." >&2; return; } if [[ -n "$SBO_RSYNC_URL" ]]; then echo "Refreshing tree (rsync, personal subtrees preserved)..." rsync_tree "$root" || echo "WARN: rsync failed; continuing." >&2 else echo "SBO_RSYNC_URL unset; skipping tree rsync (no unshadow)." >&2 fi local dir d for dir in "${!SBO_PERSONAL_TREES[@]}"; do d="$root/$dir" if [[ ! -d "$d/.git" ]]; then echo "WARN: $d is not a git clone, skipping pull." >&2 continue fi echo "Pulling $dir..." git -C "$d" pull || echo "WARN: git pull failed in $dir; continuing." >&2 done shadow_scan "$root" } # ============================================================================= # Package cache. Persistent, on-disk store of built packages so unchanged deps # are installed from cache instead of rebuilt. Layout mirrors the SBo tree: # $PKG_CACHE///---_.txz # One .txz per prog dir (the latest tested build). Key is prog+version; build # number, arch, and tag do not affect a hit. Empty PKG_CACHE disables caching. # ============================================================================= # Echo the version field of a cached package filename. is a basename like # prog-1.2-x86_64-1_danix.txz; prog may itself contain dashes, so strip the known # prog prefix first, then take the field up to the next dash. _cache_ver_of() { local prog="$1" base="$2" base="${base#"$prog"-}" # drop "prog-" echo "${base%%-*}" # version is up to the next dash } # cache_decision -> echoes: cached | bump:OLD:NEW | new cache_decision() { local cat="$1" prog="$2" version="$3" [[ -z "$PKG_CACHE" ]] && { echo new; return; } local dir="$PKG_CACHE/$cat/$prog" local f newest="" for f in "$dir/$prog"-*.t?z; do [[ -e "$f" ]] || continue [[ -z "$newest" || "$f" -nt "$newest" ]] && newest="$f" done [[ -z "$newest" ]] && { echo new; return; } local have; have="$(_cache_ver_of "$prog" "$(basename "$newest")")" if [[ "$have" == "$version" ]]; then echo cached else echo "bump:$have:$version" fi } # cache_path -> echoes the cached .txz path for that # version, or nothing. Used to installpkg a cached dep. cache_path() { local cat="$1" prog="$2" version="$3" [[ -z "$PKG_CACHE" ]] && return local dir="$PKG_CACHE/$cat/$prog" local f newest="" for f in "$dir/$prog"-*.t?z; do [[ -e "$f" ]] || continue [[ -z "$newest" || "$f" -nt "$newest" ]] && newest="$f" done [[ -z "$newest" ]] && return [[ "$(_cache_ver_of "$prog" "$(basename "$newest")")" == "$version" ]] && echo "$newest" } # lint_pkg -> run sbopkglint on a built package as a final check. # Host-side, target only (called from build_one's success branch while the # overlay is still mounted). Fail-soft: skip with a note if the tool is absent, # and never affect the build's SUCCESS status. Findings go to screen + the # package log. ponytail: no toggle flag, run iff installed. lint_pkg() { local txz="$1" logf="$2" if ! command -v sbopkglint >/dev/null 2>&1; then echo " sbopkglint not installed, skipping lint (system/sbo-maintainer-tools)" return 0 fi echo " sbopkglint $(basename "$txz") ..." echo "===== sbopkglint: $(basename "$txz") =====" >> "$logf" local out rc out="$(sbopkglint "$txz" 2>&1)"; rc=$? printf '%s\n' "$out" >> "$logf" if [[ $rc -eq 0 ]]; then echo " lint: ${C_GRN}clean${C_RST}" else # Findings: surface on screen too (indented), not just the log, so they are # easy to catch. Full copy is in the log. echo " lint: ${C_RED}findings${C_RST} (see $(basename "$logf")):" printf '%s\n' "$out" | sed 's/^/ /' fi } # cache_store -> clear the prog dir, copy src in. cache_store() { local cat="$1" prog="$2" src="$3" [[ -z "$PKG_CACHE" ]] && return local dir="$PKG_CACHE/$cat/$prog" mkdir -p "$dir" rm -f "$dir"/*.t?z cp -a "$src" "$dir/" } # ============================================================================= # 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:-}" ) } # Echo the VERSION field from a SlackBuild dir's .info (empty if unreadable). # Used as the cache key's version component. version_of() { local dir="$1" local info="$dir/$(basename "$dir").info" [[ -f "$info" ]] || return # .info lines look like: VERSION="1.2.3" local v; v="$(grep -m1 '^VERSION=' "$info" | cut -d'"' -f2)" echo "$v" } # ============================================================================= # 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" is_target="${3:-0}" 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) local version; version="$(version_of "$dir")" # %README% reminder carries through to summary [[ "${HAS_README[$dir]:-}" == "1" ]] && ST_README["$key"]=1 # Dependency with a version-matching cached package: installpkg it into the # overlay and skip the build entirely. The target never takes this path. if [[ "$is_target" != "1" ]]; then local cached; cached="$(cache_path "$cat" "$prog" "$version")" if [[ -n "$cached" ]]; then local cb; cb="$(basename "$cached")" local workroot="/sbo-work" mkdir -p "$c$workroot" cp -a "$cached" "$c$workroot/" { echo "===== sbo-batch-test: $prog (from cache) =====" echo "cached package: $cb" } >> "$logf" if chroot "$c" /bin/bash -c "installpkg --terse '$workroot/$cb'" >>"$logf" 2>&1; then ST_TIME["$key"]=$(( $(date +%s) - start )) ST_STATUS["$key"]="CACHED" return 0 fi # Cache install failed: fall through and build fresh. # installpkg is not atomic; a partial cache install is fine here because the # overlay is disposable per target. Rebuild fresh below. echo "cache install failed, building fresh" >> "$logf" fi fi # 1. copy SlackBuild dir into the overlay local workroot="/sbo-work" mkdir -p "$c$workroot" cp -a "$dir" "$c$workroot/$prog" # 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 # Force a controlled output dir so the harness, not the SlackBuild's environment, # decides where the package lands. Never inherit OUTPUT (the user's -current repo # convention sets it to /repo, which must not be touched here). export OUTPUT=/sbo-work/output mkdir -p "\$OUTPUT" # 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" 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 (written to the forced OUTPUT above) out="\$OUTPUT" 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 --terse "\$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" if [[ "$status" == "SUCCESS" ]]; then # Copy the produced package out of the overlay into the persistent cache # before teardown, so it can be reused as a dep in this or a later run. The # build wrote to the forced OUTPUT=/sbo-work/output (see the heredoc). local built newest="" for built in "$c"/sbo-work/output/${prog}-*.t?z; do [[ -e "$built" ]] || continue [[ -z "$newest" || "$built" -nt "$newest" ]] && newest="$built" done [[ -n "$newest" ]] && cache_store "$cat" "$prog" "$newest" # Final verification: lint the target's built package (host-side, overlay # still mounted so $newest is reachable). Target only; deps are vetted SBo # packages and would just add noise. [[ "$is_target" == "1" && -n "$newest" ]] && lint_pkg "$newest" "$logf" return 0 fi ST_REASON["$key"]="see $(basename "$logf")" return 1 } # Echo a human label for a package's cache outcome, given its SlackBuild dir and # whether it is the run's target. Mirrors cache_decision but renders text. # target, build (new) | target, rebuild: A -> B | cached (V) | rebuild: A -> B | build (new) cache_label() { local dir="$1" is_target="$2" local cat prog ver dec cat="$(category_of "$dir")"; prog="$(basename "$dir")"; ver="$(version_of "$dir")" dec="$(cache_decision "$cat" "$prog" "$ver")" local label case "$dec" in cached) label="cached ($ver)" ;; bump:*) label="rebuild: ${dec#bump:}"; label="${label/:/ -> }" ;; *) label="build (new)" ;; esac # The target always builds; never show it as plain "cached". if [[ "$is_target" == "1" ]]; then case "$dec" in cached) label="build (cached $ver, rebuilt as target)" ;; esac echo "target, $label" else echo "$label" fi } # ============================================================================= # 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 local it=0; [[ "$d" == "$target_dir" ]] && it=1 local rdm=""; [[ "${HAS_README[$d]:-}" == 1 ]] && rdm=" [%README%]" printf " %-30s %s%s\n" "$(pkg_key "$d")" "$(cache_label "$d" "$it")" "$rdm" echo "$(pkg_key "$d")" >> "$RUN_DIR/build-order.txt" done return fi echo " build order:" for d in "${RESOLVED_ORDER[@]}"; do local it=0; [[ "$d" == "$target_dir" ]] && it=1 printf " %-30s %s\n" "$(pkg_key "$d")" "$(cache_label "$d" "$it")" 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 local it=0; [[ "$d" == "$target_dir" ]] && it=1 echo " building $key ..." if build_one "$tmpdir" "$d" "$it"; 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 cached=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++)) ;; CACHED) col="$C_GRN"; ((cached++)) ;; 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, %s%d cached%s, total %ss\n" \ "$C_GRN" "$succ" "$C_RST" "$C_RED" "$fail" "$C_RST" "$C_YEL" "$blocked" "$C_RST" \ "$C_GRN" "$cached" "$C_RST" "$total" echo "logs: $RUN_DIR" { echo echo "$succ succeeded, $fail failed, $blocked blocked, $cached cached, total ${total}s" echo "logs: $RUN_DIR" } >> "$summary" } # ============================================================================= # main # ============================================================================= main() { parse_args "$@" init_color if [[ $INIT_BASE -eq 1 ]]; then init_base exit 0 fi if [[ $SETUP_REPO -eq 1 ]]; then setup_repo fi validate_env [[ $DO_UPDATE -eq 1 ]] && update_personal_trees 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 "$@"