#!/bin/bash # Shared helpers for the lab network simulation. # shellcheck disable=SC2034 LIBVIRT_URI="${LIBVIRT_URI:-qemu:///system}" IMG_DIR="${IMG_DIR:-/var/lib/libvirt/images/labsim}" BASE_IMAGE="${BASE_IMAGE:-$IMG_DIR/alpine-base.qcow2}" ALPINE_URL="${ALPINE_URL:-https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2}" VM_MEM="${VM_MEM:-256}" # MB — Alpine is happy here VM_CPUS="${VM_CPUS:-1}" VM_DISK="${VM_DISK:-1G}" PREFIX="labsim" CONF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vlans.conf" log() { printf '\033[0;36m[labsim]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[labsim]\033[0m %s\n' "$*" >&2; } die() { printf '\033[0;31m[labsim]\033[0m %s\n' "$*" >&2; exit 1; } virsh_q() { sudo virsh --connect "$LIBVIRT_URI" "$@"; } net_name() { echo "${PREFIX}-vlan$1"; } vm_name() { echo "${PREFIX}-$1-$2"; } # labsim-2-k8s # Linux bridge names are capped at 15 chars — keep it short and unique. br_name() { echo "vbr-ls$1"; } require_tools() { for t in virsh virt-install qemu-img genisoimage; do command -v "$t" >/dev/null 2>&1 || die "missing required tool: $t" done sudo -n true 2>/dev/null || warn "sudo may prompt for a password" } find_ssh_pubkey() { local home="${SUDO_USER:+/home/$SUDO_USER}" home="${home:-$HOME}" for n in id_ed25519 id_ecdsa id_rsa; do [ -f "$home/.ssh/$n.pub" ] && { cat "$home/.ssh/$n.pub"; return; } done die "no SSH public key found in $home/.ssh" } # Populate SELECTED[] from argv (VLAN ids) or the whole config. selected_vlans() { SELECTED=() local want=("$@") while IFS= read -r line; do [[ "$line" =~ ^[[:space:]]*# ]] && continue [[ -z "${line// }" ]] && continue local vid="${line%%:*}" if [ ${#want[@]} -eq 0 ]; then SELECTED+=("$line") else for w in "${want[@]}"; do [ "$w" = "$vid" ] && SELECTED+=("$line"); done fi done < "$CONF" [ ${#SELECTED[@]} -gt 0 ] || die "no VLANs selected (checked $CONF)" } # cloud-init NoCloud seed: static addressing + SSH key + hello-world HTTP. build_seed() { local iso="$1" vm="$2" vid="$3" name="$4" prefix="$5" ip="$6" real="$7" pubkey="$8" local tmp; tmp="$(mktemp -d)" cat > "$tmp/meta-data" < "$tmp/network-config" < "$tmp/user-data" <

labsim vlan $vid — $name

host: $vm

address: $ip/24

gateway under test: ${prefix}.1

mirrors production: $real

- path: /etc/local.d/labsim-http.start permissions: '0755' content: | #!/bin/sh # This image's busybox has no httpd applet ("applet not found"), and the # VMs are isolated so apk cannot fetch one. python3 is already present # (cloud-init depends on it), so serve with http.server — no packages, # no internet. # # Two traps already hit here, both silent: # - start-stop-daemon --exec /usr/bin/python3 matches cloud-init's OWN # python3 at boot, says "already running", starts nothing. # - busybox pgrep -f PATTERN matches its own argv, so a # "skip if running" guard always fires (verified: guard_exit=0 with # nothing listening). # So: no guard, no start-stop-daemon. Binding twice is harmless — the # second just fails to bind. nohup /usr/bin/python3 -m http.server 80 --directory /var/www \\ >/var/log/labsim-http.log 2>&1 & runcmd: # cloud-init's network-config (v1, above) already applies the address, so do # NOT restart networking here — it fails, and one failing runcmd aborts every # command after it, which is what silently left httpd unstarted. Each command # is || true for the same reason. - [ sh, -c, "rc-update add sshd default || true" ] - [ sh, -c, "rc-update add local default || true" ] - [ sh, -c, "/etc/local.d/labsim-http.start || true" ] EOF # Validate before building the ISO. The heredoc above is intentionally # unquoted (it interpolates $ip/$prefix), which means backticks or $( ) in # ANY line — including comments — get executed by the host shell and their # output silently corrupts the YAML. Cheap check, expensive bug. python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" "$tmp/user-data" \ || die "generated user-data is not valid YAML (backticks or \$( ) in build_seed?): $tmp/user-data" sudo genisoimage -quiet -output "$iso" -volid cidata -joliet -rock \ "$tmp/user-data" "$tmp/meta-data" "$tmp/network-config" rm -rf "$tmp" } ssh_to() { local ip="$1"; shift timeout 12 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o ConnectTimeout=5 -o BatchMode=yes -o LogLevel=ERROR \ "alpine@$ip" "$@" 2>/dev/null } wait_ready() { local deadline=$((SECONDS + 240)) pending=1 while [ $SECONDS -lt $deadline ]; do pending=0 for entry in "${SELECTED[@]}"; do IFS=: read -r vid _n prefix _r <<<"$entry" # Wait for BOTH: sshd is up well before cloud-init's runcmd starts the # web server, so checking SSH alone reports "ready" then shows HTTP FAIL. ssh_to "${prefix}.10" true >/dev/null 2>&1 \ && curl -sS -o /dev/null --max-time 4 "http://${prefix}.10/" 2>/dev/null \ || pending=$((pending + 1)) done [ $pending -eq 0 ] && { log "all ${#SELECTED[@]} VMs reachable"; return 0; } sleep 5 done warn "$pending VM(s) still not answering SSH after 240s — see status below" return 0 } status_table() { printf ' %-18s %-6s %-16s %-9s %-7s %s\n' VM VLAN ADDRESS STATE SSH HTTP printf ' %-18s %-6s %-16s %-9s %-7s %s\n' ------------------ ------ ---------------- --------- ------- ---- for entry in "${SELECTED[@]}"; do IFS=: read -r vid name prefix _r <<<"$entry" local vm ip state ssh http vm="$(vm_name "$vid" "$name")"; ip="${prefix}.10" state="$(virsh_q domstate "$vm" 2>/dev/null | head -1 | tr -d '\n')" [ -z "$state" ] && state="absent" ssh_to "$ip" true >/dev/null 2>&1 && ssh=ok || ssh=FAIL if curl -sS -o /dev/null --max-time 5 "http://$ip/" 2>/dev/null; then http=ok; else http=FAIL; fi printf ' %-18s %-6s %-16s %-9s %-7s %s\n' "$vm" "$vid" "$ip" "$state" "$ssh" "$http" done }