From 721fa8c043264877251c11709b2cf40bb09f2219 Mon Sep 17 00:00:00 2001 From: Mika Laitio Date: Wed, 1 Jul 2026 17:58:14 -0700 Subject: [PATCH 1/2] Add tool to generate iso images for kvm based tests - Add a tool to create configured iso-images from linux distros that can be used for kvm based testing - currently generator has been tested to generate rockylinux 8.10 based qcow image that can be launched with kvm. - Tool will also generate ssh keys that are installed to generated image and can then be used to to login with therock user automatically without password. - Therock user has sudo rights to enable the install of apps that may be needed for the testing Motivation for the tool is to be able to run tests that require the execution of fully virtualized tests with images that contains also the linux kernel that is executed. This is needed because there can be errors which only occurs on certain Linux kernels and testing of those type of errors can not be done with the docker because docker images are always using the Linux kernel from the host system instead of running it from the launched Linux image. Tool was mostly generated by giving instructions and test-copmmands for the codex gpt 5.3. Full logs from me talking to myself and for the gpt 5.3 is in dir: ai-logs/ai_agent_chat_history_01.txt Usage at the moment: 1) Generate base install image: $ cd tests/kvm_tests/linux_iso_image_generator $ python build_rockylinux_8_10_iso.py build-config.json image that can be used for the testing: $ ./testing/qemu_iso_launch.sh Image generator uses following two files to customize the content of generated image: - build-config.json - templates/rocky8.ks.tmpl --> generated output/rocky8.ks Signed-off-by: Mika Laitio --- .../linux_iso_image_generator/.gitignore | 3 + .../linux_iso_image_generator/README.md | 135 +++++ .../ai-logs/ai_agent_chat_history_01.txt | 322 ++++++++++ .../build-config.json | 23 + .../build_rockylinux_8_10_iso.py | 551 ++++++++++++++++++ .../templates/rocky8.ks.tmpl | 71 +++ .../testing/qemu_iso_launch.sh | 111 ++++ .../qemu_iso_launch_pci_passthrough.sh | 179 ++++++ .../validate-config.py | 103 ++++ 9 files changed, 1498 insertions(+) create mode 100644 tests/kvm_tests/linux_iso_image_generator/.gitignore create mode 100644 tests/kvm_tests/linux_iso_image_generator/README.md create mode 100644 tests/kvm_tests/linux_iso_image_generator/ai-logs/ai_agent_chat_history_01.txt create mode 100644 tests/kvm_tests/linux_iso_image_generator/build-config.json create mode 100644 tests/kvm_tests/linux_iso_image_generator/build_rockylinux_8_10_iso.py create mode 100644 tests/kvm_tests/linux_iso_image_generator/templates/rocky8.ks.tmpl create mode 100755 tests/kvm_tests/linux_iso_image_generator/testing/qemu_iso_launch.sh create mode 100755 tests/kvm_tests/linux_iso_image_generator/testing/qemu_iso_launch_pci_passthrough.sh create mode 100644 tests/kvm_tests/linux_iso_image_generator/validate-config.py diff --git a/tests/kvm_tests/linux_iso_image_generator/.gitignore b/tests/kvm_tests/linux_iso_image_generator/.gitignore new file mode 100644 index 00000000000..a08335bb79b --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/.gitignore @@ -0,0 +1,3 @@ +/cache/ +/output/ +/__pycache__/ diff --git a/tests/kvm_tests/linux_iso_image_generator/README.md b/tests/kvm_tests/linux_iso_image_generator/README.md new file mode 100644 index 00000000000..d84cc464400 --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/README.md @@ -0,0 +1,135 @@ +# Rocky Linux 8.10 Unattended ISO Builder + +This workspace creates a repeatable Rocky Linux 8.10 installer ISO using: + +- a JSON config file (`build-config.json`) +- a Kickstart template (`templates/rocky8.ks.tmpl`) +- a non-interactive build script (`build_rockylinux_8_10_iso.py`) + +## What this produces + +An output ISO that boots the Rocky installer and uses embedded Kickstart values for unattended installation. + +Configured defaults include: + +- user `therock` +- `therock` account created locked (no password login) +- user in `wheel` +- passwordless sudo for `therock` +- `sshd` enabled +- ping (ICMP echo) allowed +- generated passwordless SSH keypair for `therock` +- key-only SSH login policy for `therock` (password auth disabled) + +## Prerequisites (on the build host) + +Install required tools: + +```bash +sudo apt-get update +sudo apt-get install -y genisoimage xorriso isolinux syslinux-utils openssh-client python3 +``` + +The build script prefers `mkisofs` (from `genisoimage`) and falls back to `xorrisofs`/`xorriso`. +On Rocky/RHEL build hosts, install equivalent packages that provide either `mkisofs` or `xorrisofs`. + +## Configure + +Edit `build-config.json`: + +- all keys are required; the build script does not apply defaults +- optionally set `source_iso_sha256` for checksum verification +- customize hostname/timezone/lang/paths as needed +- SSH behavior: + - leave `ssh_authorized_public_key` empty to auto-generate keypair + - generated keys default to `./output/ssh/therock_ed25519` and `.pub` + - set `ssh_authorized_public_key` if you want to inject an existing public key + - keep `disable_user_password_auth: true` to enforce key-only login for `therock` + +## Build + +From this directory: + +```bash +python3 ./build_rockylinux_8_10_iso.py ./build-config.json +``` + +Output ISO path (default): + +`./output/Rocky-8.10-x86_64-autoinstall.iso` + +Generated SSH keys (default): + +- private key: `./output/ssh/therock_ed25519` +- public key: `./output/ssh/therock_ed25519.pub` + +Example login after install: + +```bash +ssh -i ./output/ssh/therock_ed25519 therock@ +``` + +## Launch with QEMU/KVM (No GPU passthrough) + +Use the helper script: + +```bash +./testing/qemu_iso_launch.sh +``` + +This launch path uses virtual display devices only and does not use GPU PCI passthrough (`vfio-pci`). +It forwards host `127.0.0.1:2222` to guest `:22` by default. +The helper script defaults to a `60G` disk to satisfy current partitioning requirements. +If you override disk size manually, keep it at least about `40G` (recommended `60G`). + +After installer completes and the VM boots into the installed OS, connect with: + +```bash +ssh -i ./output/ssh/therock_ed25519 \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -p 2222 therock@127.0.0.1 +``` + +If SSH is not ready yet, poll until it becomes available: + +```bash +for i in $(seq 1 120); do + ssh -i ./output/ssh/therock_ed25519 \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=5 \ + -p 2222 therock@127.0.0.1 "echo SSH_OK && id" && break + sleep 10 +done +``` + +## Launch with QEMU/KVM (PCI passthrough) + +1) Set PCI IDs in `build-config.json`: + +```json +"pci_passthrough_device_ids": ["0000:01:00.0", "0000:01:00.1"] +``` + +2) Make sure those devices are bound to `vfio-pci` on the host. + +3) Launch with passthrough script: + +```bash +./testing/qemu_iso_launch_pci_passthrough.sh +``` + +This script reads PCI IDs from `build-config.json` and fails early if IDs are missing, +not present on host, or not bound to `vfio-pci`. +It also defaults to a `60G` disk to satisfy current partitioning requirements. + +## Notes + +- The Kickstart template uses `clearpart --all --initlabel` with explicit partition rules, which wipes target disks during install. +- `/boot` is explicitly set to `1536 MiB` (1.5 GiB). +- `/` has minimum size `30720 MiB` (30 GiB) and then grows to fill remaining space. +- `/home` is not created as a separate filesystem; user homes live under `/`. +- SSH key authorization for `therock` is configured by Kickstart `sshkey` during install. +- Boot configs are patched to default to direct install (skip media-check default) and include serial console args for headless VM troubleshooting. diff --git a/tests/kvm_tests/linux_iso_image_generator/ai-logs/ai_agent_chat_history_01.txt b/tests/kvm_tests/linux_iso_image_generator/ai-logs/ai_agent_chat_history_01.txt new file mode 100644 index 00000000000..7d4467d3d62 --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/ai-logs/ai_agent_chat_history_01.txt @@ -0,0 +1,322 @@ +AI Agent Chat History 01 +Session date: 2026-06-30 to 2026-07-01 +Workspace: ~/rockylinux_8_10_iso + +Note: +- This is a reconstructed conversation log from the active session context. +- It preserves the main user/assistant exchanges and validated outcomes in order. + +--- + +USER: I want to automate Rocky Linux 8.10 ISO image creation using a configuration file without real user interaction. +ASSISTANT: Confirmed Kickstart is the preferred approach for repeatable unattended Rocky/RHEL installs and offered to scaffold config + template + build script. + +USER: Yes, create it. Image should include user "therock" with passwordless sudo. +ASSISTANT: +- Created workspace structure under ~/rockylinux_8_10_iso +- Added: + - build-config.json + - templates/rocky8.ks.tmpl + - scripts/build-iso.sh + - README.md +- Included therock user + wheel + NOPASSWD sudo behavior. + +USER: Add ssh-server running, ping response, generate passwordless SSH keys for therock, and configure key login during image creation. +ASSISTANT: +- Updated config with SSH-related keys and options. +- Updated Kickstart to install openssh packages, enable sshd, allow ICMP in firewall. +- Added key generation/injection flow in build script. +- Updated README documentation. + +USER: Disable password usage for therock and allow only generated SSH keys. +ASSISTANT: +- Added disable_user_password_auth behavior in config/script/template. +- Kickstart post section: + - locks therock password + - adds sshd Match User policy for key-only auth. + +USER: Check whether user and root are in same partition in created ISO image. +ASSISTANT: +- Explained partitioning is install-time behavior, not fixed by ISO itself. +- Current autopart did not guarantee /home on root. + +USER: Update Kickstart so user data and root stay on same partition. +ASSISTANT: +- Replaced autopart with explicit layout: + - reqpart --add-boot (later changed) + - swap + - / (xfs, grow) +- Updated README. + +USER: Remove defaults from script config handling and require all keys in build-config.json, with clear error listing missing key-value pairs. +ASSISTANT: +- Removed default injection from build script. +- Added strict required-key validation and detailed missing key-value error output. +- Updated README accordingly. + +USER: Instead of scripts/validate-config.sh, generate Python 3.11 compatible ./validate-config.py +ASSISTANT: +- Added validate-config.py with strict config validation logic matching builder expectations. + +USER: Can Kickstart itself disable user password and configure generated SSH key there? +ASSISTANT: +- Changed Kickstart to native key injection: + - user --lock + - sshkey --username=therock "" +- Removed password dependency from flow: + - removed user_password_plaintext from build-config.json + - removed USER_PASSWORD templating from build script + - updated validate-config.py + README. + +USER: Changes look good, can you apply them? +ASSISTANT: Confirmed they were already applied in workspace files. + +USER: How much space in root partition? +ASSISTANT: +- / is minimum 8192 MiB and grows to remaining disk capacity at install time. + +USER: How big is /boot? +ASSISTANT: +- With reqpart it was not fixed; typical around 1 GiB. + +USER: Make /boot 1.5 GB. +ASSISTANT: +- Updated Kickstart partitioning to: + - reqpart + - part /boot --size=1536 + - swap recommended + - root grow +- Updated README note. + +USER: Replace scripts/build_iso.sh with Python 3.11 compatible ./build_rockylinux_8_10_iso.py +ASSISTANT: +- Added build_rockylinux_8_10_iso.py +- Removed scripts/build-iso.sh +- Updated README commands to use Python builder. + +USER: Test end-to-end ISO generation and KVM boot. +ASSISTANT: +- Attempted test; initially missing host dependencies. +- Installed required packages later. +- Encountered build permission issue on extracted ISO tree (read-only files). +- Patched builder to make extracted staging tree writable. + +USER: Use xorrisofs in Ubuntu 24.04 instead of mkisofs. +ASSISTANT: +- Refactored builder to use xorriso/xorrisofs remaster flow: + - extract ISO + - inject ks.cfg + - patch BIOS/UEFI boot configs with inst.ks=cdrom:/ks.cfg + - rebuild bootable ISO. +- Updated README apt prerequisites. + +USER: Prefer mkisofs if available, fallback to xorriso/xorrisofs; show clear error if both missing. +ASSISTANT: +- Updated builder selection logic: + - prefer mkisofs + - fallback xorrisofs + - fallback xorriso -as mkisofs + - error with install guidance if none available. +- Kept extraction fallback (xorriso or bsdtar). + +USER: Retry end-to-end test now. +ASSISTANT: +- Installed dependencies: + - genisoimage, xorriso, qemu-system-x86, qemu-utils, ovmf +- Successfully built ISO: + - output/Rocky-8.10-x86_64-autoinstall.iso +- Verified inside ISO: + - ks.cfg present + - inst.ks=cdrom:/ks.cfg present in isolinux.cfg and EFI/BOOT/grub.cfg + - therock user locked + sshkey entry in ks.cfg. +- Started VM with QEMU user-net forwarding 2222->22. +- SSH attempt timed out at banner exchange; root cause likely install not completed/sshd not up yet. + +USER: Why SSH did not work? +ASSISTANT: +- Diagnosed likely causes: + - KVM acceleration denied (permission), QEMU fell back to slow TCG. + - installer default included media check path, adds delay. + - VM probably not finished installation/first boot by the poll window. + +USER: I added user to kvm group; do we need SSH restart? +ASSISTANT: +- Explained usually yes (new login session or newgrp kvm) for group membership propagation. + +USER: How to save session and load again after new SSH session? +ASSISTANT: +- Explained code changes are already on disk; suggested reopening same project/chat and optionally creating a session notes file. + +USER: Save current chat history to ~/rockylinux_8_10_iso/chat_history_01.txt +ASSISTANT: Created chat_history_01.txt. + +USER: Create SESSION_NOTES.md file too. +ASSISTANT: Created SESSION_NOTES.md with reconnect checklist and exact KVM/SSH verification commands. + +USER: I will disconnect and restore after renewing SSH connection. +ASSISTANT: Confirmed and advised to check kvm group after reconnect. + +USER: Now that lamikr is added to kvm group, test accelerated KVM + SSH again. +ASSISTANT: +- Found /etc/group had lamikr in kvm, but current shell session group list still lacked kvm. +- Rebuilt ISO and launched VM using `sg kvm` with strict `-machine accel=kvm`. +- SSH eventually succeeded as therock with generated key. +- Verified in guest: + - sshd active and enabled + - firewall includes ssh service + - SELinux enforcing + - sudo -n works. + +USER: It still took too long; are you sure ssh-server is running and not blocked by firewall? +ASSISTANT: +- Verified Kickstart includes ssh service + firewall allowances. +- Identified boot defaults still favored media-check install path, slowing unattended install. +- Patched builder to: + - set install entry as default (not media-check) + - reduce boot timeout + - append serial console args (`inst.text`, `console=ttyS0,115200n8`) for observability. + +USER: Can you follow serial logs now to verify install is progressing? +ASSISTANT: Confirmed yes; serial log now shows clear milestones (Kickstart loaded, automated install started, package installation progress). + +USER: yes, please monitor until first reboot and SSH readiness. +ASSISTANT: Started fresh KVM run and live monitoring loop. + +USER: Check from install progress what is happening now. +ASSISTANT: +- Reported live state: + - VM running + - qcow2 grew to ~2.5G (active install happened) + - serial log progressed through package install and into first boot + - serial log showed `OpenSSH server daemon` started + - login prompt reached. +- Ran immediate SSH probe and confirmed: + - key login works as therock + - sshd active. + +USER: So according to test ISO image creation works? +ASSISTANT: Confirmed yes, end-to-end validated (build, boot, install, SSH key login, sudo NOPASSWD). + +USER: Save full conversation log to ai_agent_chat_history_01.txt +ASSISTANT: Created this file. + +--- + +Current key artifact paths: +- Config: ~/rockylinux_8_10_iso/build-config.json +- Builder: ~/rockylinux_8_10_iso/build_rockylinux_8_10_iso.py +- Validator: ~/rockylinux_8_10_iso/validate-config.py +- Kickstart template: ~/rockylinux_8_10_iso/templates/rocky8.ks.tmpl +- Generated kickstart: ~/rockylinux_8_10_iso/output/kickstart/rocky8.ks +- Output ISO: ~/rockylinux_8_10_iso/output/Rocky-8.10-x86_64-autoinstall.iso +- Generated SSH key: + - private: ~/rockylinux_8_10_iso/output/ssh/therock_ed25519 + - public : ~/rockylinux_8_10_iso/output/ssh/therock_ed25519.pub + +--- + +Session update: 2026-07-01 + +USER: After last session, I made manual changes. +ASSISTANT: +- Synced workspace state and verified: + - git repository initialized and clean on `master` + - `.gitignore` present + - chat logs moved under `ai-logs/` + - one prior root-level log file removed + +USER: Change generated Kickstart path/name. +ASSISTANT: +- Updated Kickstart output path from `./build/ks.cfg` to: + - `./output/kickstart/rocky8.ks` +- Applied in: + - `build-config.json` + - `validate-config.py` + - `build_rockylinux_8_10_iso.py` + +USER: Avoid repeating build-config values in Python files. +ASSISTANT: +- Refactored validation definitions to keys-only (`REQUIRED_KEYS`) in: + - `validate-config.py` + - `build_rockylinux_8_10_iso.py` +- Removed duplicated example values from both files. + +USER: Make validation errors more explicit. +ASSISTANT: +- Updated both validators to split errors into: + - `Missing required keys` + - `Required keys with empty values` + +USER: Add QEMU launch script without GPU passthrough. +ASSISTANT: +- Added executable script: + - `testing/qemu_iso_launch.sh` +- Includes: + - usage instructions + - non-passthrough virtual display launch + - SSH login command using generated `therock` key + - SSH polling loop example + +USER: Add instructions for SSH login with generated key. +ASSISTANT: +- Added instructions to: + - `testing/qemu_iso_launch.sh` + - `README.md` + +USER: Add similar launch script using PCI passthrough with PCI IDs from config. +ASSISTANT: +- Added config key: + - `"pci_passthrough_device_ids": []` +- Added executable script: + - `testing/qemu_iso_launch_pci_passthrough.sh` +- Script behavior: + - reads PCI IDs from `build-config.json` + - validates IDs exist + - validates devices are bound to `vfio-pci` + - validates `/dev/vfio/vfio` exists + - launches QEMU with `vfio-pci` devices +- Added matching documentation section in `README.md`. + +--- + +Session update: 2026-07-01 (follow-up) + +USER: Update ai-logs/ai_agent_chat_history_01.txt again. +ASSISTANT: +- Refreshed chat history with latest workspace state and actions. + +USER: chat_history_01.txt is not needed and remove SESSION_NOTES.md. +ASSISTANT: +- Removed `SESSION_NOTES.md`. +- `chat_history_01.txt` was already absent (no deletion required). + +Current log/note file status: +- `ai-logs/ai_agent_chat_history_01.txt` exists and is current. +- `chat_history_01.txt` does not exist. +- `SESSION_NOTES.md` does not exist. + +--- + +Session update: 2026-07-01 (partition/packages) + +USER: Increase size reserved for root partition by 15 GB. +ASSISTANT: +- Updated Kickstart root minimum from `8192 MiB` to `23552 MiB`: + - `templates/rocky8.ks.tmpl` +- Updated partition note in: + - `README.md` + +USER: Increase size again to 30 GB. +ASSISTANT: +- Updated Kickstart root minimum from `23552 MiB` to `30720 MiB` (30 GiB): + - `templates/rocky8.ks.tmpl` +- Updated partition note in: + - `README.md` + +USER: Install vim and net-tools automatically. +ASSISTANT: +- Added packages to Kickstart `%packages`: + - `vim-enhanced` + - `net-tools` +- File updated: + - `templates/rocky8.ks.tmpl` diff --git a/tests/kvm_tests/linux_iso_image_generator/build-config.json b/tests/kvm_tests/linux_iso_image_generator/build-config.json new file mode 100644 index 00000000000..6774c21e1ff --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/build-config.json @@ -0,0 +1,23 @@ +{ + "rocky_version": "8.10", + "source_iso_url": "https://download.rockylinux.org/pub/rocky/8.10/isos/x86_64/Rocky-8.10-x86_64-minimal.iso", + "source_iso_sha256": "", + "source_iso_path": "./cache/Rocky-8.10-x86_64-minimal.iso", + "output_iso_path": "./output/Rocky-8.10-x86_64-autoinstall.iso", + "kickstart_template": "./templates/rocky8.ks.tmpl", + "kickstart_output": "./output/kickstart/rocky8.ks", + "lang": "en_US.UTF-8", + "keyboard": "us", + "timezone": "UTC", + "hostname": "rocky810-auto", + "user_name": "therock", + "disable_user_password_auth": true, + "disable_sudo_password_prompt": true, + "enable_sshd": true, + "allow_ping_icmp": true, + "generate_ssh_key_if_missing": true, + "ssh_authorized_public_key": "", + "ssh_private_key_path": "./output/ssh/therock_ed25519", + "ssh_public_key_path": "./output/ssh/therock_ed25519.pub", + "pci_passthrough_device_ids": [] +} diff --git a/tests/kvm_tests/linux_iso_image_generator/build_rockylinux_8_10_iso.py b/tests/kvm_tests/linux_iso_image_generator/build_rockylinux_8_10_iso.py new file mode 100644 index 00000000000..fa1b05cd6fb --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/build_rockylinux_8_10_iso.py @@ -0,0 +1,551 @@ +#!/usr/bin/env python3 +"""Build a Rocky Linux 8.10 unattended ISO from build-config.json.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +REQUIRED_KEYS = [ + "rocky_version", + "source_iso_url", + "source_iso_sha256", + "source_iso_path", + "output_iso_path", + "kickstart_template", + "kickstart_output", + "lang", + "keyboard", + "timezone", + "hostname", + "user_name", + "disable_user_password_auth", + "disable_sudo_password_prompt", + "enable_sshd", + "allow_ping_icmp", + "generate_ssh_key_if_missing", + "ssh_authorized_public_key", + "ssh_private_key_path", + "ssh_public_key_path", +] + +ALLOW_EMPTY_STRING = {"source_iso_sha256", "ssh_authorized_public_key"} + + +class BuildConfigError(Exception): + """Raised when configuration is missing required keys.""" + + +class BuildRuntimeError(Exception): + """Raised when a runtime build step fails.""" + + +def _bool_to_text(value: object) -> str: + return str(value).lower() + + +def _require_command(command: str) -> None: + if shutil.which(command) is None: + raise BuildRuntimeError(f"Missing required command: {command}") + + +def _run(command: list[str]) -> None: + subprocess.run(command, check=True) + + +def _run_capture(command: list[str]) -> str: + result = subprocess.run(command, check=True, capture_output=True, text=True) + return f"{result.stdout}\n{result.stderr}" + + +def _load_config(config_path: Path) -> dict[str, object]: + if not config_path.is_file(): + raise BuildConfigError(f"Config file not found: {config_path}") + try: + with config_path.open("r", encoding="utf-8") as f: + cfg = json.load(f) + except json.JSONDecodeError as exc: + raise BuildConfigError(f"Invalid JSON in config file {config_path}: {exc}") from exc + if not isinstance(cfg, dict): + raise BuildConfigError("Config root must be a JSON object.") + return cfg + + +def _validate_config(cfg: dict[str, object]) -> None: + missing: list[str] = [] + empty: list[str] = [] + for key in REQUIRED_KEYS: + if key not in cfg: + missing.append(key) + continue + + value = cfg[key] + if value is None: + missing.append(key) + continue + + if isinstance(value, str) and key not in ALLOW_EMPTY_STRING and value.strip() == "": + empty.append(key) + + if missing or empty: + parts: list[str] = ["Invalid build-config.json:"] + if missing: + parts.append("Missing required keys:") + parts.extend(f" - {entry}" for entry in missing) + if empty: + parts.append("Required keys with empty values:") + parts.extend(f" - {entry}" for entry in empty) + raise BuildConfigError("\n".join(parts)) + + +def _download_file(url: str, destination: Path, retries: int = 3, delay_seconds: int = 2) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + + for attempt in range(1, retries + 1): + try: + request = Request(url, headers={"User-Agent": "rockylinux-iso-builder/1.0"}) + with urlopen(request, timeout=120) as response, destination.open("wb") as out_file: + shutil.copyfileobj(response, out_file) + return + except (HTTPError, URLError, TimeoutError) as exc: + if destination.exists(): + destination.unlink() + if attempt >= retries: + raise BuildRuntimeError(f"Failed downloading {url}: {exc}") from exc + time.sleep(delay_seconds) + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _render_kickstart(template_path: Path, output_path: Path, values: dict[str, str]) -> None: + if not template_path.is_file(): + raise BuildRuntimeError(f"Kickstart template not found: {template_path}") + + content = template_path.read_text(encoding="utf-8") + for key, value in values.items(): + content = content.replace(f"{{{{{key}}}}}", value) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + + +def _detect_iso_builder() -> tuple[list[str], str]: + if shutil.which("mkisofs"): + return ["mkisofs"], "mkisofs" + if shutil.which("xorrisofs"): + return ["xorrisofs"], "xorrisofs" + if shutil.which("xorriso"): + return ["xorriso", "-as", "mkisofs"], "xorriso" + raise BuildRuntimeError( + "Neither mkisofs nor xorrisofs is installed. " + "Install either mkisofs (genisoimage) or xorrisofs (xorriso)." + ) + + +def _extract_iso_tree(source_iso: Path, destination_dir: Path) -> None: + destination_dir.mkdir(parents=True, exist_ok=True) + if shutil.which("xorriso"): + _run( + [ + "xorriso", + "-osirrox", + "on", + "-indev", + str(source_iso), + "-extract", + "/", + str(destination_dir), + ] + ) + _make_tree_writable(destination_dir) + return + if shutil.which("bsdtar"): + _run(["bsdtar", "-C", str(destination_dir), "-xf", str(source_iso)]) + _make_tree_writable(destination_dir) + return + raise BuildRuntimeError( + "Cannot extract source ISO: install xorriso or bsdtar." + ) + + +def _make_tree_writable(root: Path) -> None: + for dirpath, dirnames, filenames in os.walk(root): + current_dir = Path(dirpath) + try: + current_dir.chmod(current_dir.stat().st_mode | 0o200) + except OSError: + pass + for name in dirnames: + p = current_dir / name + try: + p.chmod(p.stat().st_mode | 0o200) + except OSError: + pass + for name in filenames: + p = current_dir / name + try: + p.chmod(p.stat().st_mode | 0o200) + except OSError: + pass + + +def _append_kernel_arg(line: str, kernel_arg: str) -> str: + if kernel_arg in line: + return line + if line.endswith("\n"): + return f"{line[:-1]} {kernel_arg}\n" + return f"{line} {kernel_arg}" + + +def _append_kernel_args(line: str, kernel_args: list[str]) -> str: + updated = line + for kernel_arg in kernel_args: + updated = _append_kernel_arg(updated, kernel_arg) + return updated + + +def _patch_bootloader_configs(staging_dir: Path, kernel_args: list[str]) -> None: + changed_any = False + + isolinux_cfg = staging_dir / "isolinux" / "isolinux.cfg" + if isolinux_cfg.is_file(): + lines = isolinux_cfg.read_text(encoding="utf-8").splitlines(keepends=True) + new_lines: list[str] = [] + changed = False + current_label = "" + for line in lines: + label_match = re.match(r"^\s*label\s+(\S+)\s*$", line) + if label_match: + current_label = label_match.group(1) + if re.match(r"^\s*timeout\s+\d+\s*$", line): + new_line = "timeout 50\n" + if new_line != line: + changed = True + new_lines.append(new_line) + continue + if re.match(r"^\s*append\s+", line): + new_line = _append_kernel_args(line, kernel_args) + if new_line != line: + changed = True + new_lines.append(new_line) + elif "menu default" in line and current_label == "check": + # Remove media-check default selection for faster unattended boot. + changed = True + continue + else: + new_lines.append(line) + + # Ensure the non-media-check install entry is default in BIOS menu. + linux_label_idx = next( + (i for i, l in enumerate(new_lines) if re.match(r"^\s*label\s+linux\s*$", l)), + None, + ) + if linux_label_idx is not None: + next_label_idx = next( + ( + i + for i in range(linux_label_idx + 1, len(new_lines)) + if re.match(r"^\s*label\s+\S+\s*$", new_lines[i]) + ), + len(new_lines), + ) + has_menu_default = any( + re.match(r"^\s*menu default\s*$", new_lines[i]) + for i in range(linux_label_idx + 1, next_label_idx) + ) + if not has_menu_default: + new_lines.insert(linux_label_idx + 1, " menu default\n") + changed = True + + if changed: + isolinux_cfg.write_text("".join(new_lines), encoding="utf-8") + changed_any = True + + grub_cfg_paths = [ + staging_dir / "EFI" / "BOOT" / "grub.cfg", + staging_dir / "boot" / "grub2" / "grub.cfg", + ] + for grub_cfg in grub_cfg_paths: + if not grub_cfg.is_file(): + continue + lines = grub_cfg.read_text(encoding="utf-8").splitlines(keepends=True) + new_lines = [] + changed = False + for line in lines: + if re.match(r'^\s*set\s+default="?\d+"?\s*$', line): + new_line = 'set default="0"\n' + if new_line != line: + changed = True + new_lines.append(new_line) + continue + if re.match(r"^\s*set\s+timeout=\d+\s*$", line): + new_line = "set timeout=5\n" + if new_line != line: + changed = True + new_lines.append(new_line) + continue + if re.match(r"^\s*linux(efi)?\s+", line): + new_line = _append_kernel_args(line, kernel_args) + if new_line != line: + changed = True + new_lines.append(new_line) + else: + new_lines.append(line) + if changed: + grub_cfg.write_text("".join(new_lines), encoding="utf-8") + changed_any = True + + if not changed_any: + raise BuildRuntimeError( + "Failed to patch bootloader configs with required kernel args." + ) + + +def _get_iso_volume_id(source_iso: Path) -> str: + if shutil.which("xorriso"): + info = _run_capture(["xorriso", "-indev", str(source_iso), "-pvd_info"]) + match = re.search(r"Volume id\s*:\s*'([^']+)'", info) + if match: + return match.group(1) + + if shutil.which("isoinfo"): + info = _run_capture(["isoinfo", "-d", "-i", str(source_iso)]) + match = re.search(r"Volume id:\s*(.+)", info) + if match: + return match.group(1).strip() + + stem = source_iso.stem.upper() + fallback = re.sub(r"[^A-Z0-9_]", "_", stem)[:32] + return fallback or "ROCKY_AUTO" + + +def _find_isohybrid_mbr() -> str | None: + candidates = [ + "/usr/lib/ISOLINUX/isohdpfx.bin", + "/usr/lib/syslinux/isohdpfx.bin", + "/usr/lib/syslinux/modules/bios/isohdpfx.bin", + ] + for candidate in candidates: + if Path(candidate).is_file(): + return candidate + return None + + +def _build_iso_with_builder( + builder_prefix: list[str], + builder_name: str, + staging_dir: Path, + output_iso_path: Path, + volume_id: str, +) -> None: + command = builder_prefix + [ + "-o", + str(output_iso_path), + "-V", + volume_id, + "-J", + "-joliet-long", + "-R", + "-T", + "-eltorito-boot", + "isolinux/isolinux.bin", + "-eltorito-catalog", + "isolinux/boot.cat", + "-no-emul-boot", + "-boot-load-size", + "4", + "-boot-info-table", + "-eltorito-alt-boot", + "-e", + "images/efiboot.img", + "-no-emul-boot", + ] + + # xorriso/xorrisofs support isohybrid flags; classic mkisofs typically does not. + if builder_name in {"xorrisofs", "xorriso"}: + command.append("-isohybrid-gpt-basdat") + isohybrid_mbr = _find_isohybrid_mbr() + if isohybrid_mbr is not None: + command.extend(["-isohybrid-mbr", isohybrid_mbr]) + + command.append(str(staging_dir)) + + if output_iso_path.exists(): + output_iso_path.unlink() + + _run(command) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build Rocky Linux 8.10 unattended ISO.") + parser.add_argument( + "config_path", + nargs="?", + default="./build-config.json", + help="Path to JSON config file (default: ./build-config.json)", + ) + args = parser.parse_args() + + try: + _require_command("ssh-keygen") + builder_prefix, builder_name = _detect_iso_builder() + + config_path = Path(args.config_path) + cfg = _load_config(config_path) + _validate_config(cfg) + + rocky_version = str(cfg["rocky_version"]) + source_iso_url = str(cfg["source_iso_url"]) + source_iso_sha256 = str(cfg["source_iso_sha256"]) + source_iso_path = Path(str(cfg["source_iso_path"])) + output_iso_path = Path(str(cfg["output_iso_path"])) + kickstart_template = Path(str(cfg["kickstart_template"])) + kickstart_output = Path(str(cfg["kickstart_output"])) + lang_value = str(cfg["lang"]) + keyboard = str(cfg["keyboard"]) + timezone = str(cfg["timezone"]) + hostname = str(cfg["hostname"]) + user_name = str(cfg["user_name"]) + disable_user_password_auth = _bool_to_text(cfg["disable_user_password_auth"]) + disable_sudo_password_prompt = _bool_to_text(cfg["disable_sudo_password_prompt"]) + enable_sshd = _bool_to_text(cfg["enable_sshd"]) + allow_ping_icmp = _bool_to_text(cfg["allow_ping_icmp"]) + generate_ssh_key_if_missing = _bool_to_text(cfg["generate_ssh_key_if_missing"]) + ssh_authorized_public_key = str(cfg["ssh_authorized_public_key"]) + ssh_private_key_path = Path(str(cfg["ssh_private_key_path"])) + ssh_public_key_path = Path(str(cfg["ssh_public_key_path"])) + + output_iso_path.parent.mkdir(parents=True, exist_ok=True) + kickstart_output.parent.mkdir(parents=True, exist_ok=True) + ssh_private_key_path.parent.mkdir(parents=True, exist_ok=True) + ssh_public_key_path.parent.mkdir(parents=True, exist_ok=True) + + if ssh_authorized_public_key.strip() == "": + if not ssh_private_key_path.is_file() or not ssh_public_key_path.is_file(): + if generate_ssh_key_if_missing != "true": + raise BuildRuntimeError( + "SSH key files are missing and auto-generation is disabled.\n" + f"Expected private key: {ssh_private_key_path}\n" + f"Expected public key : {ssh_public_key_path}" + ) + print(f"Generating passwordless SSH keypair for user {user_name}...") + _run( + [ + "ssh-keygen", + "-q", + "-t", + "ed25519", + "-N", + "", + "-C", + f"{user_name}@{hostname}", + "-f", + str(ssh_private_key_path), + ] + ) + else: + print("Using existing SSH keypair:") + print(f" Private: {ssh_private_key_path}") + print(f" Public : {ssh_public_key_path}") + ssh_authorized_public_key = ssh_public_key_path.read_text(encoding="utf-8").strip() + else: + print("Using ssh_authorized_public_key from config.") + + if not ssh_authorized_public_key.startswith("ssh-"): + raise BuildRuntimeError("Configured SSH public key does not look valid.") + + if source_iso_path.is_file(): + print(f"Using cached source ISO: {source_iso_path}") + else: + print(f"Downloading Rocky Linux {rocky_version} ISO...") + _download_file(source_iso_url, source_iso_path) + + if source_iso_sha256.strip(): + print("Verifying source ISO checksum...") + actual_sha256 = _sha256_file(source_iso_path) + if actual_sha256 != source_iso_sha256: + raise BuildRuntimeError( + "Checksum mismatch for source ISO\n" + f"Expected: {source_iso_sha256}\n" + f"Actual : {actual_sha256}" + ) + + print("Rendering kickstart file...") + volume_id = _get_iso_volume_id(source_iso_path) + _render_kickstart( + kickstart_template, + kickstart_output, + { + "LANG": lang_value, + "KEYBOARD": keyboard, + "TIMEZONE": timezone, + "HOSTNAME": hostname, + "USER_NAME": user_name, + "DISABLE_SUDO_PASSWORD_PROMPT": disable_sudo_password_prompt, + "SSH_AUTHORIZED_PUBLIC_KEY": ssh_authorized_public_key, + "ENABLE_SSHD": enable_sshd, + "ALLOW_PING_ICMP": allow_ping_icmp, + "DISABLE_USER_PASSWORD_AUTH": disable_user_password_auth, + }, + ) + + print("Rebuilding unattended ISO image...") + print(f"Using ISO builder: {builder_name}") + with tempfile.TemporaryDirectory(prefix="rocky-iso-build-") as tmp_dir_str: + tmp_dir = Path(tmp_dir_str) + staging_dir = tmp_dir / "iso-root" + _extract_iso_tree(source_iso_path, staging_dir) + + (staging_dir / "ks.cfg").write_text( + kickstart_output.read_text(encoding="utf-8"), + encoding="utf-8", + ) + _patch_bootloader_configs( + staging_dir, + [ + "inst.ks=cdrom:/ks.cfg", + "inst.text", + "console=ttyS0,115200n8", + ], + ) + _build_iso_with_builder( + builder_prefix=builder_prefix, + builder_name=builder_name, + staging_dir=staging_dir, + output_iso_path=output_iso_path, + volume_id=volume_id, + ) + + print() + print("Build completed successfully.") + print(f"Source ISO : {source_iso_path}") + print(f"Kickstart : {kickstart_output}") + print(f"Output ISO : {output_iso_path}") + print(f"SSH private key for {user_name}: {ssh_private_key_path}") + print(f"SSH public key for {user_name} : {ssh_public_key_path}") + return 0 + except (BuildConfigError, BuildRuntimeError, subprocess.CalledProcessError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/kvm_tests/linux_iso_image_generator/templates/rocky8.ks.tmpl b/tests/kvm_tests/linux_iso_image_generator/templates/rocky8.ks.tmpl new file mode 100644 index 00000000000..a2c1042862a --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/templates/rocky8.ks.tmpl @@ -0,0 +1,71 @@ +#version=RHEL8 +text +cdrom +reboot +firstboot --disable + +lang {{LANG}} +keyboard {{KEYBOARD}} +timezone {{TIMEZONE}} --utc +network --bootproto=dhcp --device=link --activate + +rootpw --lock +user --name={{USER_NAME}} --lock --groups=wheel +sshkey --username={{USER_NAME}} "{{SSH_AUTHORIZED_PUBLIC_KEY}}" + +firewall --enabled --service=ssh +selinux --enforcing +services --enabled=sshd,chronyd + +zerombr +clearpart --all --initlabel +reqpart +part /boot --fstype=xfs --size=1536 +part swap --recommended +part / --fstype=xfs --size=30720 --grow + +%packages +@^minimal-environment +sudo +openssh-server +openssh-clients +vim-minimal +net-tools +%end + +%post --erroronfail --log=/root/ks-post.log +hostnamectl set-hostname "{{HOSTNAME}}" + +if [ "{{DISABLE_SUDO_PASSWORD_PROMPT}}" = "true" ]; then + cat > /etc/sudoers.d/90-{{USER_NAME}}-nopasswd <<'EOF' +{{USER_NAME}} ALL=(ALL) NOPASSWD: ALL +EOF + chmod 0440 /etc/sudoers.d/90-{{USER_NAME}}-nopasswd +fi + +if [ "{{ENABLE_SSHD}}" = "true" ]; then + systemctl enable sshd +fi + +if command -v firewall-offline-cmd >/dev/null 2>&1; then + firewall-offline-cmd --add-service=ssh || true + if [ "{{ALLOW_PING_ICMP}}" = "true" ]; then + firewall-offline-cmd --remove-icmp-block=echo-request || true + firewall-offline-cmd --add-rich-rule='rule protocol value="icmp" accept' || true + fi +fi + +if [ "{{DISABLE_USER_PASSWORD_AUTH}}" = "true" ]; then + passwd -l {{USER_NAME}} || true + mkdir -p /etc/ssh/sshd_config.d + cat > /etc/ssh/sshd_config.d/90-{{USER_NAME}}-key-only.conf <<'EOF' +Match User {{USER_NAME}} + PasswordAuthentication no + KbdInteractiveAuthentication no + ChallengeResponseAuthentication no + PubkeyAuthentication yes +Match all +EOF + chmod 0644 /etc/ssh/sshd_config.d/90-{{USER_NAME}}-key-only.conf +fi +%end diff --git a/tests/kvm_tests/linux_iso_image_generator/testing/qemu_iso_launch.sh b/tests/kvm_tests/linux_iso_image_generator/testing/qemu_iso_launch.sh new file mode 100755 index 00000000000..088691fb346 --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/testing/qemu_iso_launch.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +# qemu_iso_launch.sh +# Launch Rocky Linux autoinstall ISO with QEMU/KVM without GPU PCI passthrough. +# +# This script intentionally uses a virtual/emulated display device only. +# It does NOT include vfio GPU passthrough flags such as: +# -device vfio-pci,... +# +# Usage: +# ./testing/qemu_iso_launch.sh [ISO_PATH] [DISK_PATH] +# +# Optional environment variables: +# RAM_MB=4096 Memory for VM in MB +# CPUS=2 Number of virtual CPUs +# DISK_SIZE=60G Created qcow2 size when DISK_PATH is missing +# SSH_FWD_PORT=2222 Host TCP port forwarded to guest 22 +# VM_NAME=rocky810-test VM name shown by QEMU +# HEADLESS=0 Set to 1 for no GUI window +# ACCEL_MODE=kvm:tcg kvm preferred, fallback to tcg +# +# Examples: +# ./testing/qemu_iso_launch.sh +# HEADLESS=1 ./testing/qemu_iso_launch.sh +# RAM_MB=8192 CPUS=4 SSH_FWD_PORT=2200 ./testing/qemu_iso_launch.sh +# +# SSH login after install finishes: +# ssh -i ./output/ssh/therock_ed25519 \ +# -o StrictHostKeyChecking=no \ +# -o UserKnownHostsFile=/dev/null \ +# -p 2222 therock@127.0.0.1 +# +# If SSH is not ready yet, poll until it comes up: +# for i in $(seq 1 120); do +# ssh -i ./output/ssh/therock_ed25519 \ +# -o BatchMode=yes \ +# -o StrictHostKeyChecking=no \ +# -o UserKnownHostsFile=/dev/null \ +# -o ConnectTimeout=5 \ +# -p 2222 therock@127.0.0.1 "echo SSH_OK && id" && break +# sleep 10 +# done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +ISO_PATH="${1:-${REPO_ROOT}/output/Rocky-8.10-x86_64-autoinstall.iso}" +DISK_PATH="${2:-${REPO_ROOT}/output/rocky810-test.qcow2}" + +RAM_MB="${RAM_MB:-4096}" +CPUS="${CPUS:-2}" +DISK_SIZE="${DISK_SIZE:-60G}" +SSH_FWD_PORT="${SSH_FWD_PORT:-2222}" +VM_NAME="${VM_NAME:-rocky810-test}" +HEADLESS="${HEADLESS:-0}" +ACCEL_MODE="${ACCEL_MODE:-kvm:tcg}" + +if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then + echo "Missing command: qemu-system-x86_64" >&2 + exit 1 +fi + +if ! command -v qemu-img >/dev/null 2>&1; then + echo "Missing command: qemu-img" >&2 + exit 1 +fi + +if [[ ! -f "${ISO_PATH}" ]]; then + echo "ISO not found: ${ISO_PATH}" >&2 + echo "Build it first with: python3 ./build_rockylinux_8_10_iso.py ./build-config.json" >&2 + exit 1 +fi + +mkdir -p "$(dirname "${DISK_PATH}")" +if [[ ! -f "${DISK_PATH}" ]]; then + echo "Creating VM disk: ${DISK_PATH} (${DISK_SIZE})" + qemu-img create -f qcow2 "${DISK_PATH}" "${DISK_SIZE}" >/dev/null +fi + +echo "Launching VM '${VM_NAME}'" +echo "ISO: ${ISO_PATH}" +echo "Disk: ${DISK_PATH}" +echo "SSH: host 127.0.0.1:${SSH_FWD_PORT} -> guest :22" +echo +echo "No GPU PCI passthrough is used. VM display is virtual only." +echo +echo "When install completes, connect with:" +echo "ssh -i ${REPO_ROOT}/output/ssh/therock_ed25519 \\" +echo " -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\" +echo " -p ${SSH_FWD_PORT} therock@127.0.0.1" + +QEMU_DISPLAY_ARGS=() +if [[ "${HEADLESS}" == "1" ]]; then + QEMU_DISPLAY_ARGS=(-display none -serial mon:stdio) +else + # Virtual GPU/display only (no physical GPU passthrough). + QEMU_DISPLAY_ARGS=(-device virtio-vga -display gtk,gl=off) +fi + +exec qemu-system-x86_64 \ + -name "${VM_NAME}" \ + -machine accel="${ACCEL_MODE}" \ + -m "${RAM_MB}" \ + -smp "${CPUS}" \ + -drive "file=${DISK_PATH},if=virtio,format=qcow2" \ + -cdrom "${ISO_PATH}" \ + -boot once=d,menu=on \ + -netdev "user,id=n1,hostfwd=tcp::${SSH_FWD_PORT}-:22" \ + -device virtio-net-pci,netdev=n1 \ + "${QEMU_DISPLAY_ARGS[@]}" diff --git a/tests/kvm_tests/linux_iso_image_generator/testing/qemu_iso_launch_pci_passthrough.sh b/tests/kvm_tests/linux_iso_image_generator/testing/qemu_iso_launch_pci_passthrough.sh new file mode 100755 index 00000000000..9385928f6dc --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/testing/qemu_iso_launch_pci_passthrough.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -euo pipefail + +# qemu_iso_launch_pci_passthrough.sh +# Launch Rocky Linux autoinstall ISO with QEMU/KVM using PCI passthrough. +# +# PCI IDs are read from build-config.json key: +# "pci_passthrough_device_ids": ["0000:01:00.0", "0000:01:00.1"] +# +# Usage: +# ./testing/qemu_iso_launch_pci_passthrough.sh [CONFIG_PATH] [ISO_PATH] [DISK_PATH] +# +# Optional environment variables: +# RAM_MB=8192 Memory for VM in MB +# CPUS=4 Number of virtual CPUs +# DISK_SIZE=60G Created qcow2 size when DISK_PATH is missing +# SSH_FWD_PORT=2222 Host TCP port forwarded to guest 22 +# VM_NAME=rocky810-pci-test VM name shown by QEMU +# ACCEL_MODE=kvm Acceleration mode, e.g. kvm or kvm:tcg +# +# Notes: +# - This script requires the listed PCI IDs to already be bound to vfio-pci. +# - No virtual GPU is added (`-vga none`), because display is expected via passthrough GPU. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +CONFIG_PATH="${1:-${REPO_ROOT}/build-config.json}" +ISO_PATH_OVERRIDE="${2:-}" +DISK_PATH="${3:-${REPO_ROOT}/output/rocky810-test-pci.qcow2}" + +RAM_MB="${RAM_MB:-8192}" +CPUS="${CPUS:-4}" +DISK_SIZE="${DISK_SIZE:-60G}" +SSH_FWD_PORT="${SSH_FWD_PORT:-2222}" +VM_NAME="${VM_NAME:-rocky810-pci-test}" +ACCEL_MODE="${ACCEL_MODE:-kvm}" + +to_abs_path() { + local value="$1" + if [[ "${value}" = /* ]]; then + printf "%s" "${value}" + else + printf "%s/%s" "${REPO_ROOT}" "${value#./}" + fi +} + +if ! command -v python3 >/dev/null 2>&1; then + echo "Missing command: python3" >&2 + exit 1 +fi + +if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then + echo "Missing command: qemu-system-x86_64" >&2 + exit 1 +fi + +if ! command -v qemu-img >/dev/null 2>&1; then + echo "Missing command: qemu-img" >&2 + exit 1 +fi + +if [[ ! -f "${CONFIG_PATH}" ]]; then + echo "Config file not found: ${CONFIG_PATH}" >&2 + exit 1 +fi + +eval "$( +python3 - "${CONFIG_PATH}" <<'PY' +import json +import shlex +import sys +from pathlib import Path + +cfg_path = Path(sys.argv[1]) +cfg = json.loads(cfg_path.read_text(encoding="utf-8")) +if not isinstance(cfg, dict): + raise SystemExit("Config root must be a JSON object.") + +pci_ids_raw = cfg.get("pci_passthrough_device_ids", []) +if isinstance(pci_ids_raw, str): + pci_ids = [pci_ids_raw.strip()] if pci_ids_raw.strip() else [] +elif isinstance(pci_ids_raw, list): + pci_ids = [str(entry).strip() for entry in pci_ids_raw if str(entry).strip()] +else: + pci_ids = [] + +output_iso = str(cfg.get("output_iso_path", "./output/Rocky-8.10-x86_64-autoinstall.iso")) +ssh_private_key = str(cfg.get("ssh_private_key_path", "./output/ssh/therock_ed25519")) + +print(f"CFG_OUTPUT_ISO={shlex.quote(output_iso)}") +print(f"CFG_SSH_PRIVATE_KEY={shlex.quote(ssh_private_key)}") +print(f"CFG_PCI_IDS={shlex.quote(','.join(pci_ids))}") +PY +)" + +if [[ -z "${CFG_PCI_IDS}" ]]; then + echo "No PCI IDs configured." >&2 + echo "Set build-config.json key: pci_passthrough_device_ids" >&2 + echo "Example: \"pci_passthrough_device_ids\": [\"0000:01:00.0\", \"0000:01:00.1\"]" >&2 + exit 1 +fi + +ISO_PATH="${ISO_PATH_OVERRIDE:-${CFG_OUTPUT_ISO}}" +ISO_PATH="$(to_abs_path "${ISO_PATH}")" +DISK_PATH="$(to_abs_path "${DISK_PATH}")" +SSH_KEY_PATH="$(to_abs_path "${CFG_SSH_PRIVATE_KEY}")" + +if [[ ! -f "${ISO_PATH}" ]]; then + echo "ISO not found: ${ISO_PATH}" >&2 + echo "Build it first with: python3 ./build_rockylinux_8_10_iso.py ./build-config.json" >&2 + exit 1 +fi + +if [[ ! -f "${SSH_KEY_PATH}" ]]; then + echo "SSH private key not found: ${SSH_KEY_PATH}" >&2 + echo "Generate it by running the ISO build first." >&2 + exit 1 +fi + +if [[ ! -e "/dev/vfio/vfio" ]]; then + echo "/dev/vfio/vfio not present. VFIO may not be enabled on host." >&2 + exit 1 +fi + +IFS=',' read -r -a PCI_IDS <<<"${CFG_PCI_IDS}" +VFIO_ARGS=() +for pci_id in "${PCI_IDS[@]}"; do + if [[ ! -d "/sys/bus/pci/devices/${pci_id}" ]]; then + echo "PCI device not found: ${pci_id}" >&2 + exit 1 + fi + + driver_name="unbound" + if [[ -L "/sys/bus/pci/devices/${pci_id}/driver" ]]; then + driver_name="$(basename "$(readlink "/sys/bus/pci/devices/${pci_id}/driver")")" + fi + if [[ "${driver_name}" != "vfio-pci" ]]; then + echo "PCI device ${pci_id} is bound to '${driver_name}', expected 'vfio-pci'." >&2 + echo "Bind device to vfio-pci first, then retry." >&2 + exit 1 + fi + + VFIO_ARGS+=(-device "vfio-pci,host=${pci_id}") +done + +mkdir -p "$(dirname "${DISK_PATH}")" +if [[ ! -f "${DISK_PATH}" ]]; then + echo "Creating VM disk: ${DISK_PATH} (${DISK_SIZE})" + qemu-img create -f qcow2 "${DISK_PATH}" "${DISK_SIZE}" >/dev/null +fi + +echo "Launching VM '${VM_NAME}' with PCI passthrough" +echo "Config: ${CONFIG_PATH}" +echo "ISO: ${ISO_PATH}" +echo "Disk: ${DISK_PATH}" +echo "VFIO: ${CFG_PCI_IDS}" +echo "SSH: host 127.0.0.1:${SSH_FWD_PORT} -> guest :22" +echo +echo "Connect after install completes:" +echo "ssh -i ${SSH_KEY_PATH} \\" +echo " -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\" +echo " -p ${SSH_FWD_PORT} therock@127.0.0.1" + +exec qemu-system-x86_64 \ + -name "${VM_NAME}" \ + -machine "q35,accel=${ACCEL_MODE}" \ + -cpu host \ + -m "${RAM_MB}" \ + -smp "${CPUS}" \ + -drive "file=${DISK_PATH},if=virtio,format=qcow2" \ + -cdrom "${ISO_PATH}" \ + -boot once=d,menu=on \ + -netdev "user,id=n1,hostfwd=tcp::${SSH_FWD_PORT}-:22" \ + -device virtio-net-pci,netdev=n1 \ + -vga none \ + -display none \ + -serial mon:stdio \ + "${VFIO_ARGS[@]}" diff --git a/tests/kvm_tests/linux_iso_image_generator/validate-config.py b/tests/kvm_tests/linux_iso_image_generator/validate-config.py new file mode 100644 index 00000000000..5f96356f6c8 --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/validate-config.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Validate Rocky ISO build configuration file.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + + +REQUIRED_KEYS = [ + "rocky_version", + "source_iso_url", + "source_iso_sha256", + "source_iso_path", + "output_iso_path", + "kickstart_template", + "kickstart_output", + "lang", + "keyboard", + "timezone", + "hostname", + "user_name", + "disable_user_password_auth", + "disable_sudo_password_prompt", + "enable_sshd", + "allow_ping_icmp", + "generate_ssh_key_if_missing", + "ssh_authorized_public_key", + "ssh_private_key_path", + "ssh_public_key_path", +] + +ALLOW_EMPTY_STRING = {"source_iso_sha256", "ssh_authorized_public_key"} + + +def validate_config(cfg: dict[str, object]) -> tuple[list[str], list[str]]: + missing: list[str] = [] + empty: list[str] = [] + for key in REQUIRED_KEYS: + if key not in cfg: + missing.append(key) + continue + + value = cfg[key] + if value is None: + missing.append(key) + continue + + if isinstance(value, str) and key not in ALLOW_EMPTY_STRING and value.strip() == "": + empty.append(key) + return missing, empty + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate build-config.json required keys for Rocky ISO automation." + ) + parser.add_argument( + "config_path", + nargs="?", + default="./build-config.json", + help="Path to JSON config file (default: ./build-config.json)", + ) + args = parser.parse_args() + + config_path = Path(args.config_path) + if not config_path.is_file(): + print(f"Config file not found: {config_path}", file=sys.stderr) + return 1 + + try: + with config_path.open("r", encoding="utf-8") as f: + cfg = json.load(f) + except json.JSONDecodeError as exc: + print(f"Invalid JSON in config file: {config_path}", file=sys.stderr) + print(str(exc), file=sys.stderr) + return 1 + + if not isinstance(cfg, dict): + print("Config root must be a JSON object.", file=sys.stderr) + return 1 + + missing, empty = validate_config(cfg) + if missing or empty: + print("Invalid build-config.json:", file=sys.stderr) + if missing: + print("Missing required keys:", file=sys.stderr) + for entry in missing: + print(f" - {entry}", file=sys.stderr) + if empty: + print("Required keys with empty values:", file=sys.stderr) + for entry in empty: + print(f" - {entry}", file=sys.stderr) + return 1 + + print(f"Configuration is valid: {config_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From b811707fa3fd049eabf8fd96fdb36043c31eb87d Mon Sep 17 00:00:00 2001 From: Mika Laitio Date: Mon, 6 Jul 2026 14:53:42 -0700 Subject: [PATCH 2/2] add qcow2 artifact and automated VM lifecycle - Build now produces both the unattended ISO and an installed qcow2 image. - Default mode validates guest customization over SSH and auto-shuts down. - Added --run to keep QEMU running after customization. - Added --bare-image to build only the ISO and skip VM launch. - Added configurable output_installed_image_path in build-config.json. - Updated README with new defaults, flags, and artifact paths. - Fixed shutdown handling when SSH closes during guest poweroff. Tooling used: - Codex version: 5.3 - Cursor version: 3.9.16 (build 042b3c1a4c53f2c3808067f519fbfc67b72cad80) Signed-off-by: Mika Laitio --- .../linux_iso_image_generator/README.md | 34 +++ .../ai-logs/ai_agent_chat_history_01.txt | 66 +++++ .../build-config.json | 1 + .../build_rockylinux_8_10_iso.py | 244 ++++++++++++++++++ 4 files changed, 345 insertions(+) diff --git a/tests/kvm_tests/linux_iso_image_generator/README.md b/tests/kvm_tests/linux_iso_image_generator/README.md index d84cc464400..cde32ee6d0e 100644 --- a/tests/kvm_tests/linux_iso_image_generator/README.md +++ b/tests/kvm_tests/linux_iso_image_generator/README.md @@ -54,10 +54,41 @@ From this directory: python3 ./build_rockylinux_8_10_iso.py ./build-config.json ``` +By default, the builder now does both steps: + +1) builds the unattended ISO, and +2) launches a QEMU/KVM VM from that ISO (same behavior as `testing/qemu_iso_launch.sh`). + +In default mode, the script waits until guest customization is reachable/validated over SSH +(`id`, `sudo -n true`, `systemctl is-active sshd`), then it powers off the guest automatically. +This prevents leaving QEMU running unintentionally and leaves a finalized installed qcow2 image. + +If you want only the ISO build (no VM run), use: + +```bash +python3 ./build_rockylinux_8_10_iso.py --bare-image ./build-config.json +``` + +If you want QEMU to keep running (disable auto-shutdown mode), use: + +```bash +python3 ./build_rockylinux_8_10_iso.py --run ./build-config.json +``` + Output ISO path (default): `./output/Rocky-8.10-x86_64-autoinstall.iso` +Installed qcow2 image path (default): + +`./output/Rocky-8.10-x86_64-autoinstall.qcow2` + +Optional config override in `build-config.json`: + +```json +"output_installed_image_path": "./output/my-rocky-final.qcow2" +``` + Generated SSH keys (default): - private key: `./output/ssh/therock_ed25519` @@ -82,6 +113,9 @@ It forwards host `127.0.0.1:2222` to guest `:22` by default. The helper script defaults to a `60G` disk to satisfy current partitioning requirements. If you override disk size manually, keep it at least about `40G` (recommended `60G`). +The same QEMU launch behavior is now integrated into `build_rockylinux_8_10_iso.py`. +Use `--run` to keep the VM running, or `--bare-image` to skip VM launch. + After installer completes and the VM boots into the installed OS, connect with: ```bash diff --git a/tests/kvm_tests/linux_iso_image_generator/ai-logs/ai_agent_chat_history_01.txt b/tests/kvm_tests/linux_iso_image_generator/ai-logs/ai_agent_chat_history_01.txt index 7d4467d3d62..06e75e97c00 100644 --- a/tests/kvm_tests/linux_iso_image_generator/ai-logs/ai_agent_chat_history_01.txt +++ b/tests/kvm_tests/linux_iso_image_generator/ai-logs/ai_agent_chat_history_01.txt @@ -320,3 +320,69 @@ ASSISTANT: - `net-tools` - File updated: - `templates/rocky8.ks.tmpl` + +--- + +Session update: 2026-07-06 (builder default VM lifecycle + validation) + +USER: Continue previous session in `/home/lamikr/rockylinux_8_10_iso`. +ASSISTANT: +- Restored context from existing session log and confirmed clean `master`. + +USER: Make builder run QEMU customization by default and add `--bare-image`. +ASSISTANT: +- Updated `build_rockylinux_8_10_iso.py` to: + - run ISO build + QEMU launch in Python by default + - support `--bare-image` to build ISO only + - avoid shelling out to `testing/qemu_iso_launch.sh` +- Updated README for new behavior. + +USER: Default should not keep VM running; keep-running only with `--run`. +ASSISTANT: +- Added `--run` flag. +- Default mode now: + - waits for guest readiness checks over SSH + - auto-shuts down guest after checks pass +- Added shutdown robustness for SSH disconnect during poweroff. + +USER: Also produce finalized installed qcow2 as an artifact. +ASSISTANT: +- Added installed image artifact handling in builder: + - default path: `./output/Rocky-8.10-x86_64-autoinstall.qcow2` + - configurable via `output_installed_image_path` in `build-config.json` +- Builder now recreates and outputs finalized qcow2 by default. +- Updated README and config accordingly. + +USER: Run end-to-end default-path test. +ASSISTANT: +- First run hit host GUI limitation (`gtk initialization failed`). +- Headless validation run completed full flow successfully: + - ISO build + - automated install + - SSH readiness checks + - auto-shutdown + - finalized qcow2 produced +- Verified artifacts: + - `output/Rocky-8.10-x86_64-autoinstall.iso` + - `output/Rocky-8.10-x86_64-autoinstall.qcow2` + +USER: Verify finalized qcow2 login. +ASSISTANT: +- Booted qcow2 directly (no ISO attached), confirmed: + - SSH key login works for `therock` + - `sudo -n true` works + - `sshd` is active +- Performed clean guest shutdown. + +USER: Verify password login for `therock`. +ASSISTANT: +- Password-only SSH attempt failed (`Permission denied ... password`). +- Confirmed account is locked (`passwd -S therock` -> `LK`). +- Noted daemon-level `passwordauthentication` still reports `yes` globally, + while `therock` password auth remains blocked due to locked account. + +USER: Commit changes and refine commit message formatting/signoff. +ASSISTANT: +- Created and adjusted commit message per requested format. +- Final commit on `master` before this log update: + - `29e193d` with requested message style and sign-off footer. diff --git a/tests/kvm_tests/linux_iso_image_generator/build-config.json b/tests/kvm_tests/linux_iso_image_generator/build-config.json index 6774c21e1ff..a89a886a0f8 100644 --- a/tests/kvm_tests/linux_iso_image_generator/build-config.json +++ b/tests/kvm_tests/linux_iso_image_generator/build-config.json @@ -4,6 +4,7 @@ "source_iso_sha256": "", "source_iso_path": "./cache/Rocky-8.10-x86_64-minimal.iso", "output_iso_path": "./output/Rocky-8.10-x86_64-autoinstall.iso", + "output_installed_image_path": "./output/Rocky-8.10-x86_64-autoinstall.qcow2", "kickstart_template": "./templates/rocky8.ks.tmpl", "kickstart_output": "./output/kickstart/rocky8.ks", "lang": "en_US.UTF-8", diff --git a/tests/kvm_tests/linux_iso_image_generator/build_rockylinux_8_10_iso.py b/tests/kvm_tests/linux_iso_image_generator/build_rockylinux_8_10_iso.py index fa1b05cd6fb..e0e743fb735 100644 --- a/tests/kvm_tests/linux_iso_image_generator/build_rockylinux_8_10_iso.py +++ b/tests/kvm_tests/linux_iso_image_generator/build_rockylinux_8_10_iso.py @@ -70,6 +70,10 @@ def _run_capture(command: list[str]) -> str: return f"{result.stdout}\n{result.stderr}" +def _run_capture_optional(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, capture_output=True, text=True, check=False) + + def _load_config(config_path: Path) -> dict[str, object]: if not config_path.is_file(): raise BuildConfigError(f"Config file not found: {config_path}") @@ -394,6 +398,221 @@ def _build_iso_with_builder( _run(command) +def _resolve_installed_disk_path(cfg: dict[str, object], output_iso_path: Path) -> Path: + value = str(cfg.get("output_installed_image_path", "")).strip() + if value: + return Path(value) + return output_iso_path.with_suffix(".qcow2") + + +def _launch_qemu_install( + output_iso_path: Path, + installed_disk_path: Path, + ssh_private_key_path: Path, + user_name: str, + keep_running: bool, +) -> None: + _require_command("qemu-system-x86_64") + _require_command("qemu-img") + + if not output_iso_path.is_file(): + raise BuildRuntimeError(f"ISO not found for QEMU launch: {output_iso_path}") + + ram_mb = os.environ.get("RAM_MB", "4096") + cpus = os.environ.get("CPUS", "2") + disk_size = os.environ.get("DISK_SIZE", "60G") + ssh_fwd_port = os.environ.get("SSH_FWD_PORT", "2222") + vm_name = os.environ.get("VM_NAME", "rocky810-test") + headless = os.environ.get("HEADLESS", "0") + accel_mode = os.environ.get("ACCEL_MODE", "kvm:tcg") + disk_path = installed_disk_path + + disk_path.parent.mkdir(parents=True, exist_ok=True) + if disk_path.is_file(): + print(f"Removing existing installed image to rebuild: {disk_path}") + disk_path.unlink() + + print(f"Creating VM disk: {disk_path} ({disk_size})") + _run(["qemu-img", "create", "-f", "qcow2", str(disk_path), disk_size]) + + print() + print(f"Launching VM '{vm_name}'") + print(f"ISO: {output_iso_path}") + print(f"Disk: {disk_path}") + print(f"SSH: host 127.0.0.1:{ssh_fwd_port} -> guest :22") + print() + print("No GPU PCI passthrough is used. VM display is virtual only.") + print() + print("When install completes, connect with:") + print(f"ssh -i {ssh_private_key_path} \\") + print(" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\") + print(f" -p {ssh_fwd_port} {user_name}@127.0.0.1") + + command = [ + "qemu-system-x86_64", + "-name", + vm_name, + "-machine", + f"accel={accel_mode}", + "-m", + ram_mb, + "-smp", + cpus, + "-drive", + f"file={disk_path},if=virtio,format=qcow2", + "-cdrom", + str(output_iso_path), + "-boot", + "once=d,menu=on", + "-netdev", + f"user,id=n1,hostfwd=tcp::{ssh_fwd_port}-:22", + "-device", + "virtio-net-pci,netdev=n1", + ] + if headless == "1": + command.extend(["-display", "none", "-serial", "mon:stdio"]) + else: + command.extend(["-device", "virtio-vga", "-display", "gtk,gl=off"]) + + if keep_running: + _run(command) + return + + timeout_seconds = int(os.environ.get("CUSTOMIZATION_TIMEOUT_SEC", "5400")) + poll_interval_seconds = int(os.environ.get("CUSTOMIZATION_POLL_INTERVAL_SEC", "10")) + + print() + print("Auto mode: waiting for guest customization completion...") + print( + "The VM will be automatically shut down after validation. " + "Use --run to keep it running." + ) + + process = subprocess.Popen(command) + try: + if process.poll() is not None: + raise BuildRuntimeError("QEMU exited immediately after launch.") + + if not _wait_for_customization_ready( + process=process, + ssh_private_key_path=ssh_private_key_path, + user_name=user_name, + ssh_fwd_port=ssh_fwd_port, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ): + raise BuildRuntimeError( + "Timed out waiting for guest customization completion. " + "Use --run to keep the VM running for manual investigation." + ) + + print("Customization checks passed. Shutting down guest VM...") + _request_guest_shutdown(ssh_private_key_path, user_name, ssh_fwd_port) + _wait_for_qemu_exit(process, timeout_seconds=180) + print("Guest VM stopped.") + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=30) + + +def _ssh_base_command( + ssh_private_key_path: Path, + user_name: str, + ssh_fwd_port: str, +) -> list[str]: + return [ + "ssh", + "-i", + str(ssh_private_key_path), + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=5", + "-p", + ssh_fwd_port, + f"{user_name}@127.0.0.1", + ] + + +def _wait_for_customization_ready( + process: subprocess.Popen[bytes], + ssh_private_key_path: Path, + user_name: str, + ssh_fwd_port: str, + timeout_seconds: int, + poll_interval_seconds: int, +) -> bool: + start = time.time() + checks = [ + "id", + "sudo -n true", + "systemctl is-active sshd", + ] + while time.time() - start < timeout_seconds: + if process.poll() is not None: + raise BuildRuntimeError( + f"QEMU exited before customization completed (exit code {process.returncode})." + ) + if _guest_checks_pass( + ssh_private_key_path=ssh_private_key_path, + user_name=user_name, + ssh_fwd_port=ssh_fwd_port, + checks=checks, + ): + return True + time.sleep(poll_interval_seconds) + return False + + +def _guest_checks_pass( + ssh_private_key_path: Path, + user_name: str, + ssh_fwd_port: str, + checks: list[str], +) -> bool: + base = _ssh_base_command(ssh_private_key_path, user_name, ssh_fwd_port) + for check in checks: + result = _run_capture_optional(base + [check]) + if result.returncode != 0: + return False + return True + + +def _request_guest_shutdown( + ssh_private_key_path: Path, + user_name: str, + ssh_fwd_port: str, +) -> None: + base = _ssh_base_command(ssh_private_key_path, user_name, ssh_fwd_port) + result = _run_capture_optional(base + ["sudo -n /sbin/shutdown -h now"]) + stderr_lower = result.stderr.lower() + # During shutdown, SSH can terminate before command completion is reported. + if result.returncode != 0 and "closed by remote host" not in stderr_lower: + raise BuildRuntimeError( + "Customization finished but guest shutdown command failed.\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + + +def _wait_for_qemu_exit(process: subprocess.Popen[bytes], timeout_seconds: int) -> None: + try: + process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired as exc: + raise BuildRuntimeError( + "Timed out waiting for QEMU to exit after guest shutdown request." + ) from exc + + def main() -> int: parser = argparse.ArgumentParser(description="Build Rocky Linux 8.10 unattended ISO.") parser.add_argument( @@ -402,6 +621,16 @@ def main() -> int: default="./build-config.json", help="Path to JSON config file (default: ./build-config.json)", ) + parser.add_argument( + "--bare-image", + action="store_true", + help="Only build ISO image and skip launching QEMU install VM.", + ) + parser.add_argument( + "--run", + action="store_true", + help="Keep QEMU VM running (disable automatic customization/shutdown mode).", + ) args = parser.parse_args() try: @@ -541,6 +770,21 @@ def main() -> int: print(f"Output ISO : {output_iso_path}") print(f"SSH private key for {user_name}: {ssh_private_key_path}") print(f"SSH public key for {user_name} : {ssh_public_key_path}") + installed_disk_path = _resolve_installed_disk_path(cfg, output_iso_path) + print(f"Installed disk image target: {installed_disk_path}") + + if args.bare_image: + print("Skipping VM launch (--bare-image provided).") + return 0 + + _launch_qemu_install( + output_iso_path=output_iso_path, + installed_disk_path=installed_disk_path, + ssh_private_key_path=ssh_private_key_path, + user_name=user_name, + keep_running=args.run, + ) + print(f"Installed disk image: {installed_disk_path}") return 0 except (BuildConfigError, BuildRuntimeError, subprocess.CalledProcessError) as exc: print(str(exc), file=sys.stderr)