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..a5828c5384d --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/README.md @@ -0,0 +1,217 @@ +# Rocky Linux 8.10 Unattended ISO Builder + +This workspace creates a repeatable Rocky Linux 8.10 installer ISO using: + +- a JSON config file (`build-rockylinux-8_10-config.json`) +- a Kickstart template (`templates/rocky8.ks.tmpl`) +- a non-interactive build script (`build_kvm_vm_image.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 all users (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-rockylinux-8_10-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 + - password-based SSH login is disabled globally in generated images + - keep `disable_user_password_auth: true` to also lock local password auth for `therock` + +## Build + +From this directory: + +```bash +python3 ./build_kvm_vm_image.py ./build-rockylinux-8_10-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_kvm_vm_image.py --bare-image ./build-rockylinux-8_10-config.json +``` + +If you want QEMU to keep running (disable auto-shutdown mode), use: + +```bash +python3 ./build_kvm_vm_image.py --run ./build-rockylinux-8_10-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-rockylinux-8_10-config.json`: + +```json +"output_installed_image_path": "./output/my-rocky-final.qcow2" +``` + +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@ +``` + +Verify password-based SSH auth is disabled on the guest: + +```bash +ssh -i ./output/ssh/therock_ed25519 therock@ \ + "sudo sshd -T | rg 'passwordauthentication|kbdinteractiveauthentication|challengeresponseauthentication|pubkeyauthentication|permitemptypasswords'" +``` + +Expected effective values include: + +- `passwordauthentication no` +- `kbdinteractiveauthentication no` +- `challengeresponseauthentication no` +- `pubkeyauthentication yes` +- `permitemptypasswords no` + +## 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`). + +The same QEMU launch behavior is now integrated into `build_kvm_vm_image.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 +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 finalized qcow2 image + +Use the Python helper to boot the installed image directly: + +```bash +python3 ./launch_kvm_vm_image.py +``` + +Optional positional argument: + +```bash +python3 ./launch_kvm_vm_image.py ./output/Rocky-8.10-x86_64-autoinstall.qcow2 +``` + +Use PCI passthrough mode (same vfio checks/behavior as `testing/qemu_iso_launch_pci_passthrough.sh`): + +```bash +python3 ./launch_kvm_vm_image.py --pci-p +``` + +In passthrough mode, device IDs are read from `build-rockylinux-8_10-config.json` key +`pci_passthrough_device_ids`, and each device must already be bound to `vfio-pci`. + +Environment knobs are the same as `testing/qemu_iso_launch.sh`: + +- `RAM_MB` (default `4096`) +- `CPUS` (default `2`) +- `SSH_FWD_PORT` (default `2222`) +- `VM_NAME` (default `rocky810-qcow`) +- `HEADLESS` (`1` for no GUI) +- `ACCEL_MODE` (default `kvm:tcg`) + +## Launch with QEMU/KVM (PCI passthrough) + +1) Set PCI IDs in `build-rockylinux-8_10-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-rockylinux-8_10-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..cf262716385 --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/ai-logs/ai_agent_chat_history_01.txt @@ -0,0 +1,476 @@ +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` + +--- + +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. + +--- + +Session update: 2026-07-07 (script/config renames + qcow launcher) + +USER: Continue previous session context and apply multiple file/script renames. +ASSISTANT: +- Renamed builder script in two steps, final name: + - `build_kvm_vm_image.py` +- Renamed config file: + - `build-config.json` -> `build-rockylinux-8_10-config.json` +- Updated references in docs and helper scripts to use new names. +- Kept historical references only in log file content where appropriate. + +USER: Add Python qcow launcher with same behavior as `testing/qemu_iso_launch.sh`. +ASSISTANT: +- Added new launcher: + - `launch_kvm_vm_image.py` +- Default behavior: + - boots `./output/Rocky-8.10-x86_64-autoinstall.qcow2` + - forwards SSH host port to guest `:22` + - supports same env knobs (`RAM_MB`, `CPUS`, `SSH_FWD_PORT`, `VM_NAME`, `HEADLESS`, `ACCEL_MODE`) +- Added README usage section for launching finalized qcow2 image. + +USER: Add PCI passthrough option and refine argument naming. +ASSISTANT: +- Added passthrough switch to qcow launcher with final option: + - `--pci-p` +- Implemented passthrough checks aligned with existing shell helper behavior: + - read `pci_passthrough_device_ids` from config + - verify `/dev/vfio/vfio` + - verify PCI device presence and `vfio-pci` binding + - launch QEMU with `vfio-pci` devices and passthrough flags +- Removed deprecated passthrough aliases per request. + +Current state for commit: +- updated files include README and helper scripts +- renamed/deleted/new files are staged for commit in next step + +--- + +Session update: 2026-07-07 (global SSH key-only policy) + +USER: Remove Cursor co-author trailer and prevent it from being re-added. +ASSISTANT: +- Added local repository hook: + - `.git/hooks/commit-msg` +- Hook strips this exact trailer line from commit messages: + - `Co-authored-by: Cursor ` +- Re-amended latest commit and verified trailer is no longer present. + +USER: Disable password-based SSH login for all users in generated qcow images. +ASSISTANT: +- Updated Kickstart post-install SSH config in: + - `templates/rocky8.ks.tmpl` +- Added global sshd drop-in: + - `/etc/ssh/sshd_config.d/80-global-key-only.conf` +- Enforced: + - `PasswordAuthentication no` + - `KbdInteractiveAuthentication no` + - `ChallengeResponseAuthentication no` + - `PubkeyAuthentication yes` + - `PermitEmptyPasswords no` +- Kept `disable_user_password_auth` behavior to lock local password auth for `therock`. + +USER: Add README verification command for disabled password SSH auth. +ASSISTANT: +- Updated `README.md` with post-boot verification snippet: + - `sudo sshd -T | rg 'passwordauthentication|kbdinteractiveauthentication|challengeresponseauthentication|pubkeyauthentication|permitemptypasswords'` +- Added expected effective values list for quick validation. + +--- + +Session update: 2026-07-07 (full qcow generation + ssh validation) + +USER: Run full testing that qcow generation works and verify login with `therock` SSH keys. +ASSISTANT: +- Ran full end-to-end build/install test in headless mode: + - `HEADLESS=1 python3 ./build_kvm_vm_image.py --run ./build-rockylinux-8_10-config.json` +- Confirmed build completed successfully and generated artifacts: + - `output/Rocky-8.10-x86_64-autoinstall.iso` + - `output/Rocky-8.10-x86_64-autoinstall.qcow2` +- Verified SSH key login into running VM: + - `SSH_OK` + - `id` shows user `therock` + - `sudo -n true` passed (`SUDO_OK`) +- Requested and observed clean guest shutdown after validation. +- Confirmed repository remained clean after runtime testing. diff --git a/tests/kvm_tests/linux_iso_image_generator/build-rockylinux-8_10-config.json b/tests/kvm_tests/linux_iso_image_generator/build-rockylinux-8_10-config.json new file mode 100644 index 00000000000..a89a886a0f8 --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/build-rockylinux-8_10-config.json @@ -0,0 +1,24 @@ +{ + "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", + "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", + "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_kvm_vm_image.py b/tests/kvm_tests/linux_iso_image_generator/build_kvm_vm_image.py new file mode 100644 index 00000000000..68763211536 --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/build_kvm_vm_image.py @@ -0,0 +1,795 @@ +#!/usr/bin/env python3 +"""Build a Rocky Linux 8.10 unattended ISO from build-rockylinux-8_10-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 _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}") + 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-rockylinux-8_10-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 _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( + "config_path", + nargs="?", + default="./build-rockylinux-8_10-config.json", + help="Path to JSON config file (default: ./build-rockylinux-8_10-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: + _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}") + 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) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/kvm_tests/linux_iso_image_generator/launch_kvm_vm_image.py b/tests/kvm_tests/linux_iso_image_generator/launch_kvm_vm_image.py new file mode 100644 index 00000000000..dff57cfa8c2 --- /dev/null +++ b/tests/kvm_tests/linux_iso_image_generator/launch_kvm_vm_image.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Launch a Rocky Linux qcow2 VM with QEMU/KVM.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +def _env(name: str, default: str) -> str: + return os.environ.get(name, default) + + +def _require_command(command: str) -> None: + if shutil.which(command) is None: + raise RuntimeError(f"Missing command: {command}") + + +def _load_pci_ids(config_path: Path) -> list[str]: + if not config_path.is_file(): + raise RuntimeError(f"Config file not found: {config_path}") + + try: + cfg = json.loads(config_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Invalid JSON in config file {config_path}: {exc}") from exc + + if not isinstance(cfg, dict): + raise RuntimeError("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 = [] + return pci_ids + + +def _validate_pci_devices(pci_ids: list[str]) -> None: + if not Path("/dev/vfio/vfio").exists(): + raise RuntimeError("/dev/vfio/vfio not present. VFIO may not be enabled on host.") + + for pci_id in pci_ids: + device_path = Path(f"/sys/bus/pci/devices/{pci_id}") + if not device_path.is_dir(): + raise RuntimeError(f"PCI device not found: {pci_id}") + + driver_link = device_path / "driver" + driver_name = "unbound" + if driver_link.is_symlink(): + driver_name = Path(os.readlink(driver_link)).name + if driver_name != "vfio-pci": + raise RuntimeError( + f"PCI device {pci_id} is bound to '{driver_name}', expected 'vfio-pci'." + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Launch a Rocky Linux qcow2 image in QEMU/KVM." + ) + parser.add_argument( + "image", + nargs="?", + metavar="QCOW_IMAGE", + default="./output/Rocky-8.10-x86_64-autoinstall.qcow2", + help=( + "Optional positional path to qcow2 image " + "(default: ./output/Rocky-8.10-x86_64-autoinstall.qcow2)" + ), + ) + parser.add_argument( + "--pci-p", + action="store_true", + help=( + "Use PCI passthrough mode (alias: --pci-p; " + "reads pci_passthrough_device_ids from " + "./build-rockylinux-8_10-config.json)." + ), + ) + args = parser.parse_args() + + qcow_path = Path(args.image) + ram_mb = _env("RAM_MB", "4096") + cpus = _env("CPUS", "2") + ssh_fwd_port = _env("SSH_FWD_PORT", "2222") + vm_name = _env("VM_NAME", "rocky810-qcow") + headless = _env("HEADLESS", "0") + accel_mode = _env("ACCEL_MODE", "kvm" if args.pci_passthrough else "kvm:tcg") + + try: + _require_command("qemu-system-x86_64") + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return 1 + + if not qcow_path.is_file(): + print(f"QCOW image not found: {qcow_path}", file=sys.stderr) + print( + "Build it first with: " + "python3 ./build_kvm_vm_image.py ./build-rockylinux-8_10-config.json", + file=sys.stderr, + ) + return 1 + + vfio_args: list[str] = [] + if args.pci_passthrough: + config_path = Path("./build-rockylinux-8_10-config.json") + try: + pci_ids = _load_pci_ids(config_path) + if not pci_ids: + print("No PCI IDs configured.", file=sys.stderr) + print( + "Set build-rockylinux-8_10-config.json key: " + "pci_passthrough_device_ids", + file=sys.stderr, + ) + print( + 'Example: "pci_passthrough_device_ids": ' + '["0000:01:00.0", "0000:01:00.1"]', + file=sys.stderr, + ) + return 1 + _validate_pci_devices(pci_ids) + for pci_id in pci_ids: + vfio_args.extend(["-device", f"vfio-pci,host={pci_id}"]) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return 1 + + print(f"Launching VM '{vm_name}'") + print(f"Disk: {qcow_path}") + print(f"SSH: host 127.0.0.1:{ssh_fwd_port} -> guest :22") + if args.pci_passthrough: + print("Mode: PCI passthrough") + else: + print("Mode: Virtual display (no PCI passthrough)") + print() + if args.pci_passthrough: + print("Using passthrough devices from build config.") + else: + print("No GPU PCI passthrough is used. VM display is virtual only.") + print() + print("Connect with:") + print("ssh -i ./output/ssh/therock_ed25519 \\") + print(" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\") + print(f" -p {ssh_fwd_port} therock@127.0.0.1") + + command = [ + "qemu-system-x86_64", + "-name", + vm_name, + "-machine", + f"{'q35,' if args.pci_passthrough else ''}accel={accel_mode}", + "-m", + ram_mb, + "-smp", + cpus, + "-drive", + f"file={qcow_path},if=virtio,format=qcow2", + "-netdev", + f"user,id=n1,hostfwd=tcp::{ssh_fwd_port}-:22", + "-device", + "virtio-net-pci,netdev=n1", + ] + if args.pci_passthrough: + command.extend(["-cpu", "host", "-vga", "none", "-display", "none", "-serial", "mon:stdio"]) + command.extend(vfio_args) + elif headless == "1": + command.extend(["-display", "none", "-serial", "mon:stdio"]) + else: + command.extend(["-device", "virtio-vga", "-display", "gtk,gl=off"]) + + try: + subprocess.run(command, check=True) + except subprocess.CalledProcessError as exc: + return exc.returncode + return 0 + + +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..a84ccf0cd38 --- /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 + +mkdir -p /etc/ssh/sshd_config.d +cat > /etc/ssh/sshd_config.d/80-global-key-only.conf <<'EOF' +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +PubkeyAuthentication yes +PermitEmptyPasswords no +EOF +chmod 0644 /etc/ssh/sshd_config.d/80-global-key-only.conf + +if [ "{{DISABLE_USER_PASSWORD_AUTH}}" = "true" ]; then + passwd -l {{USER_NAME}} || true +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..4e9f804a9f4 --- /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_kvm_vm_image.py ./build-rockylinux-8_10-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..54f58249d8e --- /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-rockylinux-8_10-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-rockylinux-8_10-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-rockylinux-8_10-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_kvm_vm_image.py ./build-rockylinux-8_10-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..efc8fb0f7c4 --- /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-rockylinux-8_10-config.json required keys for Rocky ISO automation." + ) + parser.add_argument( + "config_path", + nargs="?", + default="./build-rockylinux-8_10-config.json", + help="Path to JSON config file (default: ./build-rockylinux-8_10-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-rockylinux-8_10-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())