#!/bin/bash # # capture-site-content.sh - Pull the content a PXE server needs but neither # the repo nor the MCL image import provides, into the gitignored USB payload, # so a freshly installed server can build every image from the USB alone. # # Run on the build PC (not the server), with the imaging LAN reachable: # scripts/capture-site-content.sh show what would change # scripts/capture-site-content.sh --apply pull it # # What it pulls (live path -> payload dir): # winpeapps/_shared/Applications/extra/ -> site-content/shared/Applications/extra/ # Office, Adobe, OpenText, Java, fonts, Wi-Fi profile, print drivers, # Zscaler: every unattend installs from C:\Deploy\Applications\extra. # pxe-status/ is left out (the playbook deploys Send-ImagingStage.ps1). # winpeapps//Deploy/Applications/ top-level files (GE CA and Zscaler # certificates) and real folders (extra_engineering) # -> site-content/images//Applications/ # Install-EngineeringLayer.ps1 is left out (repo playbook/engineering/). # the links each image keeps in Deploy/Applications (extra, BPRT) # -> site-content/links.txt # winpeapps/_shared/BIOS/*.exe + models.txt -> bios-staging/ # models.txt pairs each model with its file; the webapp's Dell update # rewrites it, so the live copy, not the repo's, matches the binaries. # www/win11/sources/boot.wim -> boot-files/boot.wim # www/blancco/ # -> boot-tools/blancco/ # only that known set: kexec-initrd, kmod, preferences.xml and the rest # are built by the playbook on the server. # # Not pulled, on purpose: BPRT (the first MCL import seeds it, plus our # overlay from the repo), Operating Systems / Packages / drivers (the MCL # import and Upload-Image.ps1 -IncludeDrivers bring those), Blancco reports, # Clonezilla backups and imaging logs (records, not setup). # # Then it writes site-content/SHA256SUMS (the playbook checks it before it # restores anything), and refuses content a FAT32 USB cannot hold: a file of # 4 GiB or more, a name Windows rejects, or two names that differ only in case. # # Needs SSH key login as pxe on the server (files there are world-readable). # Folders are mirrored (--delete): what the server no longer has goes from # the payload too. Nothing on the server is changed. set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" SERVER="172.16.9.1" USER_="pxe" PAYLOAD_ROOT="$REPO_ROOT" APPLY=0 usage() { echo "Usage: $0 [--apply] [--server IP] [--user NAME] [--payload-root DIR]" echo " Without --apply it only shows what would change." } while [ $# -gt 0 ]; do case "$1" in --apply) APPLY=1; shift ;; --server) SERVER="${2:?}"; shift 2 ;; --user) USER_="${2:?}"; shift 2 ;; --payload-root) PAYLOAD_ROOT="$(cd "${2:?}" && pwd)"; shift 2 ;; -h|--help) usage; exit 0 ;; *) usage; exit 1 ;; esac done SHARE="${CAPTURE_SHARE:-/srv/samba/winpeapps}" # overridable for tests only WWW="${CAPTURE_WWW:-/var/www/html}" SITE="$PAYLOAD_ROOT/site-content" BIOS="$PAYLOAD_ROOT/bios-staging" BOOT_FILES="$PAYLOAD_ROOT/boot-files" BLANCCO="$PAYLOAD_ROOT/boot-tools/blancco" SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10) REMOTE="$USER_@$SERVER" DRY=(-n) [ "$APPLY" -eq 1 ] && DRY=() # rsync -a over ssh. --checksum: the same size and time is not proof of the # same content (a Blancco config.img differed that way). -L: a symlink in a # pulled folder becomes its file (FAT32 has no links). Itemized, so the dry # run lists every change. pull() { rsync -aL --checksum --mkpath --delete --itemize-changes --human-readable --stats \ -e "ssh ${SSH_OPTS[*]}" "${DRY[@]}" "$@" | summarize } # Itemized lines are noisy for 7 GB of Office; print counts and the first few. summarize() { awk ' /^[<>ch.*]/ || /^\*deleting/ { n++; if (n <= 8) print " " $0; next } /^Number of (regular )?files transferred|^Total transferred file size|^Total file size/ { s = s " " $0 "\n" } END { if (n > 8) printf " ... %d changes in all\n", n; else if (n == 0) print " (no changes)"; printf "%s", s }' } echo "Server : $REMOTE" echo "Payload: $PAYLOAD_ROOT" [ "$APPLY" -eq 1 ] && echo "Mode : APPLY" || echo "Mode : dry run (add --apply to pull)" echo ssh "${SSH_OPTS[@]}" "$REMOTE" true || { echo "ERROR: cannot ssh to $REMOTE (key login needed)" >&2; exit 1; } # --- what the server has ------------------------------------------------------ # One remote listing: images with Deploy/Applications, and per image its # top-level entries as "imagekindnamelink target". LISTING=$(ssh "${SSH_OPTS[@]}" "$REMOTE" " cd '$SHARE' || exit 1 for apps in */Deploy/Applications; do img=\${apps%%/*} case \"\$img\" in _*) continue ;; esac [ -d \"\$apps\" ] || continue for e in \"\$apps\"/* \"\$apps\"/.[!.]*; do [ -e \"\$e\" ] || [ -L \"\$e\" ] || continue n=\${e##*/} if [ -L \"\$e\" ]; then printf '%s\tlink\t%s\t%s\n' \"\$img\" \"\$n\" \"\$(readlink \"\$e\")\" elif [ -d \"\$e\" ]; then printf '%s\tdir\t%s\t\n' \"\$img\" \"\$n\" else printf '%s\tfile\t%s\t\n' \"\$img\" \"\$n\"; fi done done") IMAGES=$(printf '%s\n' "$LISTING" | cut -f1 | sort -u | grep -v '^$' || true) [ -n "$IMAGES" ] || { echo "ERROR: no image on $SERVER has Deploy/Applications" >&2; exit 1; } # Links into _shared are recreated by the playbook. Anything else is reported. LINKS="" while IFS=$'\t' read -r img kind name target; do [ "$kind" = link ] || continue case "$target" in "$SHARE"/_shared/*) LINKS+="$img $name ${target#"$SHARE"/}"$'\n' ;; *) echo "WARNING: $img/Deploy/Applications/$name links outside _shared ($target); not captured" ;; esac done <<< "$LISTING" # --- 1. _shared/Applications/extra ------------------------------------------- echo "[1/5] _shared/Applications/extra -> site-content/shared/Applications/extra/" pull --exclude=/pxe-status/ "$REMOTE:$SHARE/_shared/Applications/extra/" "$SITE/shared/Applications/extra/" # --- 2. per-image Applications content --------------------------------------- echo "[2/5] per-image Deploy/Applications files and folders -> site-content/images/" for img in $IMAGES; do names=$(printf '%s\n' "$LISTING" | awk -F'\t' -v i="$img" '$1 == i && ($2 == "file" || $2 == "dir") { print $3 }') # BPRT as a real folder is an old import's copy of the vendor folder: not ours. names=$(printf '%s\n' "$names" | grep -vix 'bprt' || true) if [ -z "$names" ]; then echo " $img: nothing (links only)" [ "$APPLY" -eq 1 ] && rm -rf "$SITE/images/$img" continue fi echo " $img: $(printf '%s\n' "$names" | tr '\n' ' ')" filter=() while IFS= read -r n; do [ -n "$n" ] && filter+=(--include="/$n" --include="/$n/***") done <<< "$names" pull --exclude=Install-EngineeringLayer.ps1 "${filter[@]}" --exclude='*' \ --delete-excluded "$REMOTE:$SHARE/$img/Deploy/Applications/" "$SITE/images/$img/Applications/" done # --- 3. BIOS -------------------------------------------------------------------- echo "[3/5] _shared/BIOS (*.exe, models.txt) -> bios-staging/" pull --include='/*.exe' --include=/models.txt --exclude='*' --delete-excluded \ "$REMOTE:$SHARE/_shared/BIOS/" "$BIOS/" # --- 4. boot.wim ----------------------------------------------------------------- echo "[4/5] win11/sources/boot.wim -> boot-files/boot.wim" pull "$REMOTE:$WWW/win11/sources/boot.wim" "$BOOT_FILES/boot.wim" # --- 5. Blancco (the known set only) --------------------------------------------- echo "[5/5] blancco (files already in boot-tools/blancco/) -> boot-tools/blancco/" if [ -d "$BLANCCO" ] && [ -n "$(ls -A "$BLANCCO")" ]; then filter=() for e in "$BLANCCO"/*; do n=${e##*/} filter+=(--include="/$n") [ -d "$e" ] && filter+=(--include="/$n/***") done pull "${filter[@]}" --exclude='*' "$REMOTE:$WWW/blancco/" "$BLANCCO/" else echo " SKIP: boot-tools/blancco/ is empty (run scripts/prepare-boot-tools.sh first)" fi if [ "$APPLY" -ne 1 ]; then echo echo "Dry run: nothing written. Re-run with --apply to pull." exit 0 fi # --- links, checks, checksums --------------------------------------------------- printf '# imagename in Deploy/Applicationslink target under winpeapps/\n%s' "$LINKS" > "$SITE/links.txt" echo echo "Checking what a FAT32 USB can hold..." BAD=0 BIG=$(find "$SITE" "$BIOS" "$BOOT_FILES/boot.wim" -type f -size +4194303k 2>/dev/null || true) if [ -n "$BIG" ]; then echo "ERROR: files of 4 GiB or more (FAT32 cannot hold them):"; printf '%s\n' "$BIG" | sed 's/^/ /'; BAD=1 fi ILLEGAL=$(cd "$SITE" && find . -name '*[<>:"|?*\\]*' 2>/dev/null || true) if [ -n "$ILLEGAL" ]; then echo "ERROR: names Windows/FAT32 rejects:"; printf '%s\n' "$ILLEGAL" | sed 's/^/ /'; BAD=1 fi CASE=$(cd "$SITE" && find . | sort -f | uniq -di || true) if [ -n "$CASE" ]; then echo "ERROR: names that differ only in case (one would overwrite the other):" printf '%s\n' "$CASE" | sed 's/^/ /'; BAD=1 fi [ "$BAD" -eq 0 ] || exit 1 echo " ok" echo "Writing site-content/SHA256SUMS..." # Written beside site-content, not in it, so the list never names itself. ( cd "$SITE" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum ) > "$PAYLOAD_ROOT/.site-content.SHA256SUMS.tmp" mv "$PAYLOAD_ROOT/.site-content.SHA256SUMS.tmp" "$SITE/SHA256SUMS" echo echo "Summary:" printf ' site-content/ %s, %s files\n' "$(du -sh "$SITE" | cut -f1)" "$(grep -c . "$SITE/SHA256SUMS")" printf ' links.txt %s links\n' "$(grep -vc '^#' "$SITE/links.txt" || true)" printf ' bios-staging/ %s, %s .exe\n' "$(du -sh "$BIOS" | cut -f1)" "$(find "$BIOS" -name '*.exe' | wc -l)" printf ' boot.wim %s\n' "$(du -h "$BOOT_FILES/boot.wim" | cut -f1)" # Files the repo tracks that the server has a different copy of: review and # commit (or revert) before building the USB. cd "$REPO_ROOT" if [ "$PAYLOAD_ROOT" = "$REPO_ROOT" ] && ! git diff --quiet -- boot-tools/blancco/grub-blancco.cfg 2>/dev/null; then echo echo "NOTE: boot-tools/blancco/grub-blancco.cfg (tracked) now holds the server's copy:" git --no-pager diff --stat -- boot-tools/blancco/grub-blancco.cfg fi for f in check-bios.cmd hardware-report.ps1; do if ssh "${SSH_OPTS[@]}" "$REMOTE" "cat '$SHARE/_shared/BIOS/$f'" 2>/dev/null \ | cmp -s - "playbook/shopfloor-setup/BIOS/$f"; then :; else echo "NOTE: the server's _shared/BIOS/$f differs from playbook/shopfloor-setup/BIOS/$f (the repo copy is installed)" fi done echo echo "Done. Build the USB next (scripts/build-usb.sh copies site-content/)."