diff --git a/.gitignore b/.gitignore index 376bdbd535..38ad5e2946 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ SAFETY_LOCK **/**.egg* **/**.sh !archinstall/locales/locales_generator.sh +!scripts/dev_vm.sh **/**.egg-info/ **/**build/ **/**src/ @@ -39,5 +40,8 @@ requirements.txt /.gitconfig /actions-runner /cmd_output.txt +/.dev-iso/ +/.dev-configs/ +/.dev-logs/ node_modules/ uv.lock diff --git a/scripts/derive_packages.py b/scripts/derive_packages.py new file mode 100755 index 0000000000..1f84774d2e --- /dev/null +++ b/scripts/derive_packages.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Parse pyproject.toml runtime dependencies and emit Arch package names. + +Assumes the python- convention, which holds for every archinstall +dependency today. If a future dep breaks the convention, fix here. +""" + +import re +import sys +import tomllib + + +def main() -> int: + if len(sys.argv) != 2: + print(f'usage: {sys.argv[0]} ', file=sys.stderr) + return 1 + + with open(sys.argv[1], 'rb') as f: + data = tomllib.load(f) + + for dep in data['project']['dependencies']: + name = re.split(r'[<>=!~;\[\s]', dep, maxsplit=1)[0].strip() + if name: + # PEP 503: lowercase, any run of [-_.] becomes '-'. Arch mirrors this + # naming, so normalize before prepending the python- prefix. + name = re.sub(r'[-_.]+', '-', name).lower() + print(f'python-{name}') + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/dev_vm.sh b/scripts/dev_vm.sh new file mode 100755 index 0000000000..6347e1109e --- /dev/null +++ b/scripts/dev_vm.sh @@ -0,0 +1,285 @@ +#!/bin/bash +# Dev test VM for archinstall. +# Builds a minimal dev ISO on first run (runtime deps pre-installed, 9p shares +# auto-mounted, `archinstall` wrapped to clear logs and run `python -m archinstall`), +# then launches QEMU. Source lives on the host, guest mounts it read-only - no rebuild loop. +# +# Host artifacts (created in repo root, all git-ignored): +# .dev-iso/ generated dev ISO (mkarchiso output) +# .dev-disk.qcow2 VM disk image (qemu-img) +# .dev-ovmf-vars.fd persistent UEFI NVRAM (copied from OVMF_VARS) +# .dev-configs/ optional, user-created - shared rw to guest as /root/cfg +# .dev-logs/ auto-created - shared rw to guest as /var/log/archinstall +# +# Inside the guest after boot: +# /root/archinstall-dev project source (9p ro, host edits appear live) +# /root/cfg .dev-configs share (9p rw, mounted only if folder exists) +# /var/log/archinstall .dev-logs share (9p rw, host can tail install.log live) +# archinstall wrapper around `python -m archinstall`; clears the +# log directory on every invocation +# +# Run from the project root or from inside scripts/ - both work, the script +# resolves the project root from its own location. +# +# Usage (from project root): +# ./scripts/dev_vm.sh - build ISO if missing, fresh disk, boot +# ./scripts/dev_vm.sh rebuild | r - force rebuild ISO, fresh disk, boot +# ./scripts/dev_vm.sh keep | k - reuse disk, boot ISO +# ./scripts/dev_vm.sh boot | b - boot from installed disk (no ISO) +# ./scripts/dev_vm.sh clean | c - remove disk, NVRAM, ISO, logs +# ./scripts/dev_vm.sh -h - show this help +# +# Env overrides: +# SCREEN_W, SCREEN_H - virtio-vga resolution (default 1280x720) +# +# Host FS note: 9p mapped-xattr mode needs xattr support on the filesystem +# holding the repo (ext4/btrfs work out of the box; ZFS requires xattr=sa). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +CONFIGS_DIR="$PROJECT_DIR/.dev-configs" +LOGS_DIR="$PROJECT_DIR/.dev-logs" +ISO_DIR="$PROJECT_DIR/.dev-iso" +DISK="$PROJECT_DIR/.dev-disk.qcow2" +OVMF_VARS="$PROJECT_DIR/.dev-ovmf-vars.fd" +DISK_SIZE="30G" +RAM="4G" +CPUS="4" +SCREEN_W="${SCREEN_W:-1280}" +SCREEN_H="${SCREEN_H:-720}" +ARG="${1:-default}" + +# OVMF firmware - probed at runtime across common distro paths +OVMF_CODE="" +OVMF_VARS_ORIG="" + +err() { echo "ERROR: $*" >&2; exit 1; } + +probe_ovmf() { + local pairs=( + "/usr/share/edk2/x64/OVMF_CODE.4m.fd:/usr/share/edk2/x64/OVMF_VARS.4m.fd" + "/usr/share/edk2-ovmf/x64/OVMF_CODE.fd:/usr/share/edk2-ovmf/x64/OVMF_VARS.fd" + "/usr/share/edk2/ovmf/OVMF_CODE.fd:/usr/share/edk2/ovmf/OVMF_VARS.fd" + "/usr/share/OVMF/OVMF_CODE_4M.fd:/usr/share/OVMF/OVMF_VARS_4M.fd" + "/usr/share/OVMF/OVMF_CODE.fd:/usr/share/OVMF/OVMF_VARS.fd" + ) + local pair code vars + for pair in "${pairs[@]}"; do + code="${pair%%:*}" + vars="${pair##*:}" + if [ -f "$code" ] && [ -f "$vars" ]; then + OVMF_CODE="$code" + OVMF_VARS_ORIG="$vars" + return 0 + fi + done + err "OVMF firmware not found. Install 'edk2-ovmf' (Arch), 'ovmf' (Debian/Ubuntu), or 'edk2-ovmf' (Fedora)." +} + +check_host_deps() { + local missing=() cmd + for cmd in qemu-system-x86_64 qemu-img sudo; do + command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd") + done + if [ ${#missing[@]} -gt 0 ]; then + err "missing host commands: ${missing[*]} (install qemu-base and sudo)" + fi +} + +# ISO build needs pacman + mkarchiso, which are Arch-only. Accept Arch and +# Arch-based derivatives (Manjaro, EndeavourOS, ...) via ID / ID_LIKE. +check_arch_host() { + local id="" id_like="" + if [ -r /etc/os-release ]; then + # shellcheck disable=SC1091 + . /etc/os-release + id="${ID:-}" + id_like="${ID_LIKE:-}" + fi + if [ "$id" != "arch" ] && [[ "$id_like" != *arch* ]]; then + err "ISO build requires an Arch-based host (pacman + mkarchiso). Detected ID='${id:-unknown}', ID_LIKE='${id_like:-unknown}'." + fi +} + +# Parse runtime deps from pyproject.toml and map to Arch package names. +# Delegates to derive_packages.py so the Python logic lives in a real .py file. +derive_packages() { + command -v python3 >/dev/null 2>&1 || err "python3 not found on host (needed to parse pyproject.toml)" + local result + result=$(python3 "$SCRIPT_DIR/derive_packages.py" "$PROJECT_DIR/pyproject.toml") \ + || err "failed to parse $PROJECT_DIR/pyproject.toml" + [ -n "$result" ] || err "pyproject.toml has no [project.dependencies]" + printf '%s\n' "$result" +} + +find_iso() { + ls -t "$ISO_DIR"/archlinux-*-x86_64.iso 2>/dev/null | head -n1 +} + +build_iso() { + check_arch_host + if ! command -v mkarchiso >/dev/null 2>&1; then + echo ">>> mkarchiso not found on host; archiso will be installed via sudo pacman." + fi + local runtime_deps + runtime_deps=$(derive_packages) + echo ">>> Runtime deps derived from pyproject.toml:" + echo "$runtime_deps" | sed 's/^/ /' + echo ">>> Building dev ISO (sudo needed for pacman/mkarchiso)..." + sudo env OUT_DIR="$ISO_DIR" HOST_USER="$(id -un)" RUNTIME_DEPS="$runtime_deps" bash <<'SUDO_SCRIPT' +set -e +BUILD_DIR=/tmp/archlive-dev + +# Runtime deps - python plus the list derived from pyproject.toml on the host. +# Source comes via 9p, no wheel build needed. +packages=(python git) +while IFS= read -r p; do + [ -n "$p" ] && packages+=("$p") +done <<< "$RUNTIME_DEPS" + +rm -rf "$BUILD_DIR" +pacman --noconfirm --needed -S archiso +cp -r /usr/share/archiso/configs/releng "$BUILD_DIR" + +# Drop preinstalled archinstall - we run from 9p-mounted source. +# Anchored pattern so related packages (archinstall-*, python-archinstall-*) +# would not be accidentally removed if releng ever grows them. +sed -i '/^archinstall$/d' "$BUILD_DIR/packages.x86_64" +for p in "${packages[@]}"; do + echo "$p" >> "$BUILD_DIR/packages.x86_64" +done + +# Trust the 9p-mounted source for git: host UIDs differ from the guest's, +# which would otherwise trip git's safe.directory dubious-ownership check. +mkdir -p "$BUILD_DIR/airootfs/etc" +cat > "$BUILD_DIR/airootfs/etc/gitconfig" <<'GIT' +[safe] + directory = /root/archinstall-dev +GIT + +# Auto-mount project, define archinstall wrapper, print info on login +mkdir -p "$BUILD_DIR/airootfs/root" +cat > "$BUILD_DIR/airootfs/root/.zprofile" <<'ZP' +mkdir -p /root/archinstall-dev /root/cfg /var/log/archinstall +if ! mount -t 9p -o trans=virtio,ro dev /root/archinstall-dev 2>/dev/null; then + echo "ERROR: failed to mount 9p 'dev' share. The archinstall wrapper will not work." + echo "Check host qemu virtfs support and that the guest kernel has the 9p module." +fi +mount -t 9p -o trans=virtio cfg /root/cfg 2>/dev/null || true +mount -t 9p -o trans=virtio logs /var/log/archinstall 2>/dev/null || true +cd /root/archinstall-dev +export PYTHONDONTWRITEBYTECODE=1 +# Wrapper instead of a plain alias so each invocation starts with a clean log +# directory. The directory is a 9p mount from the host, so the contents are +# wiped (find -delete) instead of removing the mountpoint itself. +archinstall() { + find /var/log/archinstall -mindepth 1 -delete 2>/dev/null + python -m archinstall "$@" +} +cat <>> ISO built." +} + +case "$ARG" in + -h|--help|help) + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + clean|c) + rm -fv "$DISK" "$OVMF_VARS" + rm -rf "$ISO_DIR" "$LOGS_DIR" + exit 0 + ;; + boot|b) + check_host_deps + probe_ovmf + [ -f "$DISK" ] || err "Disk missing, run without args first" + BOOT_ORDER="c" + ATTACH_ISO=false + ;; + rebuild|r) + check_host_deps + probe_ovmf + rm -rf "$ISO_DIR" + build_iso + rm -f "$DISK" + qemu-img create -f qcow2 "$DISK" "$DISK_SIZE" + BOOT_ORDER="d" + ATTACH_ISO=true + ;; + keep|k) + check_host_deps + probe_ovmf + [ -f "$DISK" ] || err "Disk missing, run without args first" + [ -n "$(find_iso)" ] || build_iso + BOOT_ORDER="d" + ATTACH_ISO=true + ;; + default) + check_host_deps + probe_ovmf + [ -n "$(find_iso)" ] || build_iso + rm -f "$DISK" + qemu-img create -f qcow2 "$DISK" "$DISK_SIZE" + BOOT_ORDER="d" + ATTACH_ISO=true + ;; + *) + err "Unknown argument: $ARG (try -h)" + ;; +esac + +[ -f "$OVMF_VARS" ] || cp "$OVMF_VARS_ORIG" "$OVMF_VARS" + +QEMU_ARGS=( + -machine q35 + -cpu host + -enable-kvm + -m "$RAM" + -smp "$CPUS" + -drive "file=$DISK,format=qcow2,if=virtio" + -device virtio-net-pci,netdev=net0 + -netdev user,id=net0 + -device "virtio-vga,xres=$SCREEN_W,yres=$SCREEN_H" + -display gtk,zoom-to-fit=off + -monitor stdio + -drive "if=pflash,format=raw,readonly=on,file=$OVMF_CODE" + -drive "if=pflash,format=raw,file=$OVMF_VARS" + -virtfs "local,path=$PROJECT_DIR,mount_tag=dev,security_model=mapped-xattr,readonly=on" + -boot "order=$BOOT_ORDER" +) + +# Optional second 9p share for test configs, only if host folder exists +if [ -d "$CONFIGS_DIR" ]; then + QEMU_ARGS+=(-virtfs "local,path=$CONFIGS_DIR,mount_tag=cfg,security_model=mapped-xattr") +fi + +# Logs share: always mounted, host folder auto-created so install.log is +# visible from the host (.dev-logs/install.log) without re-entering the VM. +mkdir -p "$LOGS_DIR" +QEMU_ARGS+=(-virtfs "local,path=$LOGS_DIR,mount_tag=logs,security_model=mapped-xattr") + +if [ "$ATTACH_ISO" = "true" ]; then + ISO="$(find_iso)" + [ -n "$ISO" ] && [ -f "$ISO" ] || err "No dev ISO after build step" + QEMU_ARGS+=(-cdrom "$ISO") +fi + +exec qemu-system-x86_64 "${QEMU_ARGS[@]}"