#!/usr/bin/env bash # Install-CIToolchain-Linux2404.sh # Guest-side CI toolchain setup for Ubuntu 24.04 LTS build template. # Run as ci_build (passwordless sudo) via: sudo /tmp/Install-CIToolchain-Linux2404.sh # Called by: template/Prepare-LinuxBuild2404.ps1 (host-side) # # Usage: # sudo /tmp/Install-CIToolchain-Linux2404.sh [--skip-update] [--dotnet] [--mingw] # # Options: # --skip-update Skip apt update + upgrade (faster re-runs) # --dotnet Install .NET SDK 10.0 (optional, adds ~500 MB) # --no-mingw Skip mingw-w64 cross-compiler (installed by default) # # Exit codes: # 0 All steps passed, system ready for snapshot # 1 One or more steps failed (output shows which step and why) set -euo pipefail # --- Option parsing --- SKIP_UPDATE=0 INSTALL_DOTNET=0 INSTALL_MINGW=1 while [[ $# -gt 0 ]]; do case "$1" in --skip-update) SKIP_UPDATE=1 ;; --dotnet) INSTALL_DOTNET=1 ;; --no-mingw) INSTALL_MINGW=0 ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac shift done # --- Assert helper --- STEP=0 assert_step() { local desc="$1" shift STEP=$((STEP + 1)) if "$@"; then echo " [OK] [Step ${STEP}] ${desc}" else echo " [FAIL] [Step ${STEP}] ${desc}" >&2 exit 1 fi } # --- Step 1: apt update + upgrade --- echo "" echo "=== Step 1: apt update + upgrade ===" if [ "${SKIP_UPDATE:-0}" = "0" ]; then sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq assert_step "apt update + upgrade completed" true else echo " [SKIP] --skip-update flag set" fi # --- Step 2: build essentials --- echo "" echo "=== Step 2: build essentials ===" sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ build-essential gcc g++ clang \ make cmake ninja-build \ pkg-config autoconf automake libtool assert_step "gcc available" command -v gcc assert_step "g++ available" command -v g++ assert_step "clang available" command -v clang assert_step "cmake available" command -v cmake assert_step "make available" command -v make # --- Step 3: Python --- echo "" echo "=== Step 3: Python ===" sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-pip python3-venv sudo ln -sf /usr/bin/python3 /usr/local/bin/python assert_step "python3 available" command -v python3 assert_step "pip3 available" command -v pip3 # --- Step 4: Tier-1 toolchain --- echo "" echo "=== Step 4: Tier-1 toolchain (git, 7-zip, curl, wget) ===" sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git p7zip-full curl wget ca-certificates assert_step "git available" command -v git assert_step "7z available" command -v 7z assert_step "curl available" command -v curl # --- Step 4b: .NET SDK (optional) --- echo "" echo "=== Step 4b: .NET SDK (optional) ===" if [ "${INSTALL_DOTNET:-0}" = "1" ]; then curl -fsSL https://dotnet.microsoft.com/download/dotnet/scripts/v1/dotnet-install.sh \ | sudo bash -s -- --channel 10.0 --install-dir /usr/local/dotnet sudo ln -sf /usr/local/dotnet/dotnet /usr/local/bin/dotnet assert_step "dotnet available" command -v dotnet else echo " [SKIP] --dotnet not specified" fi # --- Step 4c: mingw-w64 cross-compiler (default ON: required by ns7zip Linux build) --- echo "" echo "=== Step 4c: mingw-w64 cross-compiler ===" if [ "$INSTALL_MINGW" = "1" ]; then sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ binutils-mingw-w64-i686 binutils-mingw-w64-x86-64 \ gcc-mingw-w64-i686 gcc-mingw-w64-x86-64 \ g++-mingw-w64-i686 g++-mingw-w64-x86-64 assert_step "i686-w64-mingw32-gcc available" command -v i686-w64-mingw32-gcc assert_step "x86_64-w64-mingw32-gcc available" command -v x86_64-w64-mingw32-gcc assert_step "i686-w64-mingw32-g++ available" command -v i686-w64-mingw32-g++ assert_step "x86_64-w64-mingw32-g++ available" command -v x86_64-w64-mingw32-g++ else echo " [SKIP] --no-mingw passed, skipping mingw-w64" fi # --- Step 5: CI directories + sudo group --- echo "" echo "=== Step 5: CI directories ===" sudo mkdir -p /opt/ci/{build,output,scripts,cache} sudo chown -R ci_build:ci_build /opt/ci # Ensure ci_build is in the sudo group (belt-and-suspenders alongside sudoers.d entry) sudo usermod -aG sudo ci_build assert_step "/opt/ci/build exists" test -d /opt/ci/build assert_step "/opt/ci/output exists" test -d /opt/ci/output assert_step "/opt/ci/scripts exists" test -d /opt/ci/scripts assert_step "/opt/ci/cache exists" test -d /opt/ci/cache assert_step "ci_build owns /opt/ci" test "$(stat -c '%U' /opt/ci)" = "ci_build" # --- Step 6: SSH hardening --- echo "" echo "=== Step 6: SSH hardening ===" sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config sudo sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config sudo systemctl restart ssh assert_step "PasswordAuthentication no in sshd_config" grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config assert_step "sshd service active" systemctl is-active ssh # --- Step 7: disable swap --- echo "" echo "=== Step 7: disable swap ===" sudo swapoff -a sudo sed -i '/ swap / s/^/#/' /etc/fstab assert_step "no active swap" bash -c '[ -z "$(swapon --show)" ]' # --- Step 7a: MAC-agnostic netplan for linked clones --- # Cloud-init generates /etc/netplan/50-cloud-init.yaml with the template's MAC # pinned via `match: macaddress: ...`. When VMware creates a linked clone with # a new MAC, that match fails and the clone has no network. We add a second # netplan file that matches any en* interface by name, so any clone (any MAC) # gets DHCP. The two files are merged; if 50-cloud-init also matches, fine — # we just guarantee DHCP works regardless. echo "" echo "=== Step 7a: MAC-agnostic netplan for clones ===" sudo tee /etc/netplan/99-ci-dhcp-allnics.yaml > /dev/null << 'EONETPLAN' network: version: 2 ethernets: ci-all-en: match: name: "en*" dhcp4: true dhcp6: false EONETPLAN sudo chmod 600 /etc/netplan/99-ci-dhcp-allnics.yaml sudo chown root:root /etc/netplan/99-ci-dhcp-allnics.yaml sudo netplan generate assert_step "99-ci-dhcp-allnics.yaml exists" test -f /etc/netplan/99-ci-dhcp-allnics.yaml # --- Step 7b: CI IP reporter via VMware guestinfo --- # The host reads the guest IP via `vmrun readVariable guestVar ci-ip`. # IMPORTANT: vmware-rpctool sets 'guestinfo.ci-ip' but vmrun reads it as just # 'ci-ip' under the guestVar namespace (the 'guestinfo.' prefix is implicit). # This is the official VMware VMCI channel — works even when TCP/IP is not yet # fully initialised on the host. Used as primary; getGuestIPAddress is fallback. echo "" echo "=== Step 7b: CI IP reporter ===" sudo tee /usr/local/bin/ci-report-ip.sh > /dev/null << 'EOSCRIPT' #!/bin/bash # Writes the VM's primary IPv4 to VMware guestinfo.ci-ip on every boot. # Called by ci-report-ip.service. # Retries for up to 60s to tolerate open-vm-tools re-init after UUID change # (linked clones get a new UUID — vmware-rpctool may fail briefly on first boot). for i in $(seq 1 30); do IP=$(ip -4 route get 1.0.0.0 2>/dev/null \ | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' \ | head -1) if [ -n "$IP" ]; then if /usr/bin/vmware-rpctool "info-set guestinfo.ci-ip $IP" 2>/dev/null; then echo "ci-report-ip: set guestinfo.ci-ip=$IP (attempt $i)" | systemd-cat -t ci-report-ip exit 0 fi fi sleep 2 done echo "ci-report-ip: failed to set guestinfo.ci-ip after 30 attempts" | systemd-cat -t ci-report-ip exit 1 EOSCRIPT sudo chmod +x /usr/local/bin/ci-report-ip.sh sudo tee /etc/systemd/system/ci-report-ip.service > /dev/null << 'EOSERVICE' [Unit] Description=Report CI VM IP to VMware host via guestinfo After=open-vm-tools.service network.target Wants=open-vm-tools.service [Service] Type=oneshot ExecStart=/usr/local/bin/ci-report-ip.sh RemainAfterExit=yes TimeoutStartSec=120 [Install] WantedBy=multi-user.target EOSERVICE sudo systemctl daemon-reload sudo systemctl enable ci-report-ip.service assert_step "ci-report-ip.service enabled" systemctl is-enabled ci-report-ip.service # --- Step 8: disable apt auto-update --- echo "" echo "=== Step 8: disable apt auto-update ===" sudo systemctl mask apt-daily.service apt-daily-upgrade.service \ apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y unattended-upgrades 2>/dev/null || true assert_step "apt-daily.timer masked or inactive" bash -c \ 'systemctl is-enabled apt-daily.timer 2>/dev/null | grep -qE "masked|disabled" || true' # --- Step 9: cleanup pre-snapshot --- echo "" echo "=== Step 9: cleanup pre-snapshot ===" sudo apt-get clean sudo rm -rf /var/lib/apt/lists/* sudo journalctl --vacuum-time=1d 2>/dev/null || true sudo rm -rf /tmp/* /var/tmp/* 2>/dev/null || true history -c 2>/dev/null || true cat /dev/null > ~/.bash_history 2>/dev/null || true assert_step "apt cache cleaned" bash -c '[ -z "$(ls /var/cache/apt/archives/*.deb 2>/dev/null)" ]' # --- Final Gate: pre-snapshot validation --- echo "" echo "=== Final Gate: pre-snapshot validation ===" for tool in gcc g++ clang cmake make git 7z curl python3 pip3; do assert_step "${tool} in PATH" command -v "${tool}" done for d in /opt/ci/build /opt/ci/output /opt/ci/scripts /opt/ci/cache; do assert_step "${d} exists" test -d "${d}" done assert_step "ci_build owns /opt/ci" test "$(stat -c '%U' /opt/ci)" = "ci_build" assert_step "no active swap" bash -c '[ -z "$(swapon --show)" ]' assert_step "sshd active" systemctl is-active ssh assert_step "PasswordAuthentication no" grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config assert_step "root fs >= 38 GB" bash -c \ 'df -BG / | awk '\''NR==2{gsub("G","",$2); if ($2+0 < 38) {print "FAIL: root fs too small ("$2"G)"; exit 1}}'\''' assert_step "/tmp writable" bash -c 'touch /tmp/.ci_tmpcheck && rm /tmp/.ci_tmpcheck' assert_step "ci-report-ip.service enabled" systemctl is-enabled ci-report-ip.service echo "" echo "=== Install-CIToolchain-Linux2404.sh complete ===" echo " All steps passed. VM is ready for BaseClean-Linux snapshot." echo " Run Prepare-LinuxBuild2404.ps1 --Step7Shutdown + snapshot from host."