Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ SAFETY_LOCK
**/**.egg*
**/**.sh
!archinstall/locales/locales_generator.sh
!test_tooling/dev_vm/dev_vm.sh
**/**.egg-info/
**/**build/
**/**src/
Expand Down Expand Up @@ -39,6 +40,7 @@ requirements.txt
/.gitconfig
/actions-runner
/cmd_output.txt
/.dev/
node_modules/
uv.lock
test_tooling/mkosi/mkosi.output/*image*
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,24 @@ replace the archinstall version with a newer one and execute the subsequent step
rare case it will not work is if the source has introduced any new dependencies that are not installed yet
- Installing the branch version with `pip install --break-system-packages .` and `archinstall`

## Developer VM (recommended for contributors)

The fastest way to iterate on archinstall is the dev VM script in `test_tooling/dev_vm/`.
It builds a minimal Arch ISO with runtime deps pre-installed, then launches QEMU with the
project source mounted read-only via 9p - edit code on the host, run `archinstall` in the
guest immediately, no rebuild needed.

```shell
./test_tooling/dev_vm/dev_vm.sh # first run builds ISO, then boots
./test_tooling/dev_vm/dev_vm.sh rebuild # force rebuild ISO
./test_tooling/dev_vm/dev_vm.sh boot # boot from installed disk (no ISO)
./test_tooling/dev_vm/dev_vm.sh --bios # legacy BIOS mode
./test_tooling/dev_vm/dev_vm.sh -h # all options
```

Requires an Arch-based host with `qemu-base` installed. See the script header for details
on 9p shares, log streaming, and env overrides.

## Without a Live ISO Image

To test this without a live ISO, the simplest approach is to use a local image and create a loop device.<br>
Expand Down
33 changes: 33 additions & 0 deletions test_tooling/dev_vm/derive_packages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Parse pyproject.toml runtime dependencies and emit Arch package names.

Assumes the python-<name> 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]} <path-to-pyproject.toml>', 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())
307 changes: 307 additions & 0 deletions test_tooling/dev_vm/dev_vm.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
#!/bin/bash
Comment thread
Softer marked this conversation as resolved.
# 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 (all under .dev/ in repo root, 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 test_tooling/dev_vm/ - both work,
# the script resolves the project root from its own location.
#
# Usage (from project root):
# ./test_tooling/dev_vm/dev_vm.sh - build ISO if missing, fresh disk, boot
# ./test_tooling/dev_vm/dev_vm.sh rebuild | r - force rebuild ISO, fresh disk, boot
# ./test_tooling/dev_vm/dev_vm.sh keep | k - reuse disk, boot ISO
# ./test_tooling/dev_vm/dev_vm.sh boot | b - boot from installed disk (no ISO)
# ./test_tooling/dev_vm/dev_vm.sh clean | c - remove disk, NVRAM, ISO, logs
# ./test_tooling/dev_vm/dev_vm.sh -h - show this help
#
# Firmware mode (default is UEFI 64-bit):
# --bios - legacy BIOS boot (SeaBIOS, no OVMF)
#
# Flag can be combined with a command:
# ./test_tooling/dev_vm/dev_vm.sh --bios rebuild
#
# 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)"
DEV_DIR="$PROJECT_DIR/.dev"
CONFIGS_DIR="$DEV_DIR/configs"
LOGS_DIR="$DEV_DIR/logs"
ISO_DIR="$DEV_DIR/iso"
DISK="$DEV_DIR/disk.qcow2"
OVMF_VARS="$DEV_DIR/ovmf-vars.fd"
DISK_SIZE="30G"
RAM="4G"
CPUS="4"
SCREEN_W="${SCREEN_W:-1280}"
SCREEN_H="${SCREEN_H:-720}"

err() { echo "ERROR: $*" >&2; exit 1; }

# Parse flags and command from arguments
FW_MODE="uefi64"
ARG="default"
for arg in "$@"; do
case "$arg" in
--bios)
FW_MODE="bios"
;;
*)
ARG="$arg"
;;
esac
done

# OVMF firmware - probed at runtime across common distro paths
OVMF_CODE=""
OVMF_VARS_ORIG=""

probe_ovmf() {
if [ -f /usr/share/edk2/x64/OVMF_CODE.4m.fd ] && [ -f /usr/share/edk2/x64/OVMF_VARS.4m.fd ]; then
OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
OVMF_VARS_ORIG="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
return 0
fi
echo ">>> OVMF firmware not found. Installing edk2-ovmf (sudo needed for UEFI support)..."
sudo pacman --noconfirm -S edk2-ovmf
if [ -f /usr/share/edk2/x64/OVMF_CODE.4m.fd ] && [ -f /usr/share/edk2/x64/OVMF_VARS.4m.fd ]; then
OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
OVMF_VARS_ORIG="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
return 0
fi
err "x64 OVMF not found at /usr/share/edk2/x64/ even after installing edk2-ovmf."
}

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 <<MSG

=== archinstall dev environment ===
Source: /root/archinstall-dev (9p, read-only, live host edits)
Configs: /root/cfg (9p, optional, if .dev/configs on host)
Logs: /var/log/archinstall (9p, host sees install.log live in .dev/logs/)
Run: archinstall (wraps 'python -m archinstall'; clears /var/log/archinstall first)

MSG
ZP

mkdir -p "$OUT_DIR"
cd "$BUILD_DIR"
mkarchiso -v -w work/ -o "$OUT_DIR" ./
chown -R "$HOST_USER:" "$OUT_DIR"
SUDO_SCRIPT
echo ">>> 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
[ "$FW_MODE" != "bios" ] && probe_ovmf
[ -f "$DISK" ] || err "Disk missing, run without args first"
BOOT_ORDER="c"
ATTACH_ISO=false
;;
rebuild|r)
check_host_deps
[ "$FW_MODE" != "bios" ] && 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
[ "$FW_MODE" != "bios" ] && 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
[ "$FW_MODE" != "bios" ] && 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

if [ "$FW_MODE" != "bios" ]; then
[ -f "$OVMF_VARS" ] || cp "$OVMF_VARS_ORIG" "$OVMF_VARS"
fi

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
-virtfs "local,path=$PROJECT_DIR,mount_tag=dev,security_model=mapped-xattr,readonly=on"
-boot "order=$BOOT_ORDER"
)

if [ "$FW_MODE" != "bios" ]; then
QEMU_ARGS+=(
-drive "if=pflash,format=raw,readonly=on,file=$OVMF_CODE"
-drive "if=pflash,format=raw,file=$OVMF_VARS"
)
fi

# 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[@]}"