mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
`vm run` took 41s cold / 30s warm to run `true`. Both were vmbox waiting on itself. Now 12s / 0s, with all five shutdown verdicts re-tested unchanged. - _run_settle waited up to 30s for the kernel's "reboot: Power down" marker UNCONDITIONALLY, including on the majority of runs that leave the guest up, where that marker is never going to appear. It now settles only when the guest has actually stopped, or when ssh returned 255 (what a successful poweroff looks like from the host). - The ssh readiness polls used a 5s ConnectTimeout. QEMU's user-mode networking ACCEPTS the forwarded connection while the guest still has nothing on :22, so each probe blocked for the full timeout instead of failing fast: a guest reachable at ~10s was reported up at ~45s. Fixed in both the sandbox and build paths, via an env override -- ssh honours the FIRST value for an option, so a second -o could not have worked. Making runs fast exposed a race: sshd now answers BEFORE tty1's autologin has started Xorg, so the X environment could be missing on the first run after a boot. `VMBOX_X_WAIT=<seconds>` waits for the session; it defaults to 0 so an ordinary run never pays for a graphical session it is not using. Build: the host's /var/cache/pacman/pkg is offered to the build guest read-only (`readonly=on` AND an `ro` mount, because it is the real host cache) as an extra CacheDir after the guest's own writable one, so a package the host lacks still downloads rather than failing the build. This removes the 396 MiB the build used to fetch. Host cache verified byte-for-byte identical across four builds: same file count, same size, same mtime. Build time itself moved only 103.8s -> 100.3s. Per-phase timings (also added here) show why: 47s of it is the build guest's cloud-init first boot, and that is NOT datasource probing -- `cloud-init analyze blame` totals 2.27s. Left unexplained rather than guessed at; SESSION_RESULTS.md records the evidence. Also: `vm scp`, which lib/ssh.sh could always do but the CLI never exposed, and provision.sh split at the 250-line cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
226 lines
9.6 KiB
Bash
226 lines
9.6 KiB
Bash
#!/bin/bash
|
|
# ============================================================================
|
|
# vmbox: golden base image build.
|
|
#
|
|
# Downloads the signed Arch cloud image, provisions it once, then SEALS it:
|
|
# chmod 444 + a sha256 sidecar. Every sandbox is a thin qcow2 overlay on this
|
|
# file, so booting the base even once would silently corrupt every overlay
|
|
# derived from it -- hence the seal and the launch-time verification.
|
|
# ============================================================================
|
|
|
|
# shellcheck source=common.sh
|
|
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
|
# shellcheck source=seed.sh
|
|
source "$(dirname "${BASH_SOURCE[0]}")/seed.sh"
|
|
# shellcheck source=smoke.sh
|
|
source "$(dirname "${BASH_SOURCE[0]}")/smoke.sh"
|
|
|
|
readonly CLOUD_URL="https://geo.mirror.pkgbuild.com/images/latest"
|
|
readonly CLOUD_IMG="Arch-Linux-x86_64-cloudimg.qcow2"
|
|
readonly BASE_DISK_SIZE="${BASE_DISK_SIZE:-20G}"
|
|
readonly BUILD_SSH_PORT="${BUILD_SSH_PORT:-2222}"
|
|
# The host's own package cache, offered to the build guest read-only.
|
|
readonly HOST_PKG_CACHE="${VMBOX_HOST_PKG_CACHE:-/var/cache/pacman/pkg}"
|
|
|
|
# Per-phase timing. "The build got faster" is only checkable against phase
|
|
# numbers: a single wall-clock figure hides which change did the work, and
|
|
# the download share in particular varies with what the host cache holds.
|
|
_phase_start=0
|
|
phase_begin() { printf -v _phase_start '%(%s)T' -1; log "$1"; }
|
|
phase_end() {
|
|
local now
|
|
printf -v now '%(%s)T' -1
|
|
ok "$1: $(( now - _phase_start ))s"
|
|
}
|
|
|
|
base_fetch() {
|
|
# Separate statements: within a single `local a=.. b="$a.."`, the later
|
|
# reference is unbound under `set -u` because the assignment is not done yet.
|
|
local dest="$VMBOX_BASE_DIR/cloudimg.qcow2"
|
|
local tmp="$dest.tmp"
|
|
local sums="$dest.SHA256"
|
|
[[ -f "$dest" ]] && { log "Cloud image present, skipping download"; return 0; }
|
|
|
|
install -d -m 755 "$VMBOX_BASE_DIR"
|
|
log "Downloading Arch cloud image (~531 MB)"
|
|
curl -fL --progress-bar -o "$tmp" "$CLOUD_URL/$CLOUD_IMG"
|
|
curl -fsSL -o "$sums" "$CLOUD_URL/$CLOUD_IMG.SHA256"
|
|
|
|
local expected actual
|
|
expected="$(awk '{print $1}' "$sums")"
|
|
actual="$(sha256sum "$tmp" | awk '{print $1}')"
|
|
[[ "$expected" == "$actual" ]] ||
|
|
die "checksum mismatch -- refusing to use image (expected $expected, got $actual)"
|
|
mv "$tmp" "$dest"
|
|
ok "cloud image verified"
|
|
}
|
|
|
|
# Boot the working copy with the seed ISO and an ssh forward, provision over
|
|
# ssh (real exit codes, unlike driving a shell over the serial console), then
|
|
# shut down cleanly.
|
|
base_provision() {
|
|
local disk="$1" work seed serial qpid
|
|
work="$(mktemp -d)"
|
|
seed="$work/seed.iso"
|
|
serial="$VMBOX_BASE_DIR/build.log"
|
|
rm -f "$serial"
|
|
|
|
seed_ensure_key
|
|
seed_build_iso "$seed" "$work"
|
|
|
|
# Offer the host's own pacman cache to the build guest, READ-ONLY. The
|
|
# host cache already holds every package this image installs, so this is
|
|
# the difference between fetching them over the internet and reading them
|
|
# off local disk.
|
|
#
|
|
# Read-only is enforced at BOTH layers -- `readonly=on` here and `ro` in
|
|
# the guest's mount -- because a guest that could write here would be
|
|
# writing into the real host system's package cache, which is the one
|
|
# thing this design must never allow. The guest still keeps its own
|
|
# writable /var/cache/pacman/pkg; this mount is only an extra CacheDir,
|
|
# so a package the host cache lacks is downloaded normally rather than
|
|
# failing the build.
|
|
local -a cache_args=()
|
|
if [[ -d "$HOST_PKG_CACHE" ]]; then
|
|
cache_args=(-virtfs
|
|
"local,path=$HOST_PKG_CACHE,mount_tag=pkgcache,security_model=none,readonly=on")
|
|
log "Sharing the host pacman cache read-only ($(du -sh "$HOST_PKG_CACHE" 2>/dev/null | cut -f1))"
|
|
else
|
|
warn "no host pacman cache at $HOST_PKG_CACHE -- packages will be downloaded"
|
|
fi
|
|
|
|
log "Booting image for provisioning"
|
|
qemu-system-x86_64 \
|
|
-machine q35,accel=kvm -cpu host -smp 4 -m 4096 \
|
|
-drive file="$disk",if=virtio,format=qcow2 \
|
|
-drive file="$seed",if=virtio,format=raw,readonly=on \
|
|
-netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${BUILD_SSH_PORT}-:22" \
|
|
-device virtio-net-pci,netdev=net0 \
|
|
"${cache_args[@]}" \
|
|
-display none \
|
|
-chardev file,id=ser0,path="$serial",append=on -serial chardev:ser0 \
|
|
-no-reboot >/dev/null 2>&1 &
|
|
qpid=$!
|
|
|
|
# Always reap the build VM, including on failure or Ctrl-C. The pid and
|
|
# workdir are baked into the trap body: a RETURN trap fires after the
|
|
# function's locals are gone, so referencing $qpid there is unbound.
|
|
# shellcheck disable=SC2064 # expanding NOW is intended: see comment above
|
|
trap "kill $qpid 2>/dev/null || true; rm -rf '$work'" RETURN
|
|
|
|
phase_begin "Waiting for the provisioning guest to come up"
|
|
_base_wait_ssh "$qpid" || die "guest never became reachable -- see $serial"
|
|
phase_end "PHASE first-boot"
|
|
|
|
phase_begin "Provisioning guest (installs Xorg/i3 + dev tools)"
|
|
# provision.sh sources a sibling (provision-desktop.sh) by path, so BOTH
|
|
# must land in the same directory in the guest -- copying only the entry
|
|
# point produces a build that dies partway with "No such file or directory".
|
|
_base_scp "$VMBOX_GUEST_DIR/provision.sh" "/tmp/provision.sh"
|
|
_base_scp "$VMBOX_GUEST_DIR/provision-desktop.sh" "/tmp/provision-desktop.sh"
|
|
_base_ssh "chmod +x /tmp/provision.sh /tmp/provision-desktop.sh && sudo /tmp/provision.sh '$VMBOX_GUEST_USER'" ||
|
|
die "provisioning failed -- see $serial"
|
|
phase_end "PHASE provision"
|
|
|
|
# Disable cloud-init so per-VM boots skip datasource probing entirely
|
|
# (it adds startup delay and would re-run on every overlay).
|
|
#
|
|
# Do NOT run `cloud-init clean` here: on this image cloud-init is what
|
|
# brings sshd up, and cleaning strips that alongside its own state,
|
|
# producing a sealed image whose guests boot fine but are unreachable.
|
|
# provision.sh enables sshd.service independently, which is what makes
|
|
# disabling cloud-init safe at all.
|
|
_base_ssh "sudo touch /etc/cloud/cloud-init.disabled" || true
|
|
|
|
# Assert the sealed image will actually be reachable. A base that boots but
|
|
# refuses ssh is the single most expensive failure here: it looks like a
|
|
# network bug and costs a full rebuild to find.
|
|
_base_ssh "test -e /etc/systemd/system/multi-user.target.wants/sshd.service" ||
|
|
die "sshd.service is not enabled in the image -- guests would be unreachable"
|
|
ok "verified: sshd.service will start on boot"
|
|
|
|
log "Shutting the build VM down cleanly"
|
|
_base_ssh "sudo systemctl poweroff" >/dev/null 2>&1 || true
|
|
|
|
local waited=0
|
|
while kill -0 "$qpid" 2>/dev/null && (( waited < 120 )); do
|
|
sleep 2; waited=$(( waited + 2 ))
|
|
done
|
|
kill -0 "$qpid" 2>/dev/null && { warn "build VM did not stop; forcing"; kill "$qpid"; }
|
|
ok "provisioning complete"
|
|
}
|
|
|
|
_base_ssh_opts() {
|
|
printf '%s' "-i $VMBOX_SSH_KEY -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
-o LogLevel=ERROR -o ConnectTimeout=${BUILD_SSH_CONNECT_TIMEOUT:-5} -p $BUILD_SSH_PORT"
|
|
}
|
|
|
|
_base_ssh() {
|
|
# shellcheck disable=SC2046 # word splitting of ssh opts is intended
|
|
ssh $(_base_ssh_opts) "${VMBOX_GUEST_USER}@127.0.0.1" "$@"
|
|
}
|
|
|
|
_base_scp() {
|
|
scp -i "$VMBOX_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
-o LogLevel=ERROR -P "$BUILD_SSH_PORT" "$1" "${VMBOX_GUEST_USER}@127.0.0.1:$2"
|
|
}
|
|
|
|
# ssh is its own port prober, so no nc/socat needed (neither is installed).
|
|
_base_wait_ssh() {
|
|
local qpid="$1" start now deadline
|
|
log "Waiting for guest ssh (first boot runs cloud-init)"
|
|
printf -v start '%(%s)T' -1
|
|
deadline=$(( start + 300 ))
|
|
# Poll every 1s against a wall-clock deadline, not every 5s against a
|
|
# count of sleeps. The old loop over-reported by up to 5s of sleep plus
|
|
# the ConnectTimeout each failed probe burns inside ssh, so a guest that
|
|
# was reachable at ~22s was recorded as "reachable after 45s" -- and that
|
|
# inflated figure was then treated as cloud-init being slow.
|
|
while :; do
|
|
kill -0 "$qpid" 2>/dev/null || return 1
|
|
printf -v now '%(%s)T' -1
|
|
(( now < deadline )) || return 1
|
|
# Short connect timeout: QEMU's user-mode networking accepts the
|
|
# forwarded connection before the guest listens on :22, so a probe
|
|
# against a booting guest blocks for the WHOLE timeout rather than
|
|
# failing fast. It must go through the env var -- ssh honours the
|
|
# FIRST value given for an option, so a second -o here is ignored.
|
|
if BUILD_SSH_CONNECT_TIMEOUT=1 _base_ssh -o BatchMode=yes true 2>/dev/null; then
|
|
printf -v now '%(%s)T' -1
|
|
ok "guest reachable after $(( now - start ))s"
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
}
|
|
|
|
# Make the base read-only and record its hash. The hash is the guard that
|
|
# actually catches corruption; chmod alone can be bypassed.
|
|
base_seal() {
|
|
local disk="$1"
|
|
log "Sealing base image"
|
|
mv "$disk" "$VMBOX_BASE_IMG"
|
|
chmod 444 "$VMBOX_BASE_IMG"
|
|
sha256sum "$VMBOX_BASE_IMG" | awk '{print $1}' > "$VMBOX_BASE_SHA"
|
|
ok "base sealed: $VMBOX_BASE_IMG ($(du -h "$VMBOX_BASE_IMG" | cut -f1))"
|
|
}
|
|
|
|
base_build() {
|
|
require_host_deps
|
|
if [[ -f "$VMBOX_BASE_IMG" && "${1:-}" != "--force" ]]; then
|
|
die "base image already exists -- rebuild with: vm build --force"
|
|
fi
|
|
base_fetch
|
|
|
|
local disk="$VMBOX_BASE_DIR/building.qcow2"
|
|
rm -f "$disk"
|
|
cp --reflink=auto "$VMBOX_BASE_DIR/cloudimg.qcow2" "$disk"
|
|
qemu-img resize -q "$disk" "$BASE_DISK_SIZE"
|
|
|
|
base_provision "$disk"
|
|
base_seal "$disk"
|
|
base_smoke_test
|
|
echo
|
|
ok "Done. Create a sandbox with: vm new demo"
|
|
}
|