Enhance ISO selection logic and update documentation for improved clarity and usage instructions

This commit is contained in:
Philip Henning 2026-03-08 18:56:58 +01:00
parent b6886cb34a
commit 9a7bd81d17
12 changed files with 287 additions and 16 deletions

View file

@ -1,14 +1,61 @@
# Base Proxmox endpoint, including scheme and port.
# Required for live API access.
PROXMOX_URL=https://proxmox.example.invalid:8006 PROXMOX_URL=https://proxmox.example.invalid:8006
# Login realm used for authentication.
# Required for live API access.
PROXMOX_REALM=pam PROXMOX_REALM=pam
# Proxmox username. If it does not already include @realm, the app appends PROXMOX_REALM.
# Required for live API access.
PROXMOX_USER=root PROXMOX_USER=root
# Password for the configured user.
# Required for live API access.
PROXMOX_PASSWORD=replace-me PROXMOX_PASSWORD=replace-me
# Verify TLS certificates for API requests.
# Recommended: true for trusted certificates, false only for known self-signed/internal setups.
PROXMOX_VERIFY_TLS=false PROXMOX_VERIFY_TLS=false
# Usually leave this at the default Proxmox API base.
PROXMOX_API_BASE=/api2/json PROXMOX_API_BASE=/api2/json
# Optional ISO auto-selection rule for the OS step.
# Uses glob syntax by default. Prefix with "regex:" to use a regular expression.
# Examples:
# PROXMOX_DEFAULT_ISO_SELECTOR=*ubuntu*
# PROXMOX_DEFAULT_ISO_SELECTOR=regex:nixos-minimal-\d{2}\.\d{2}\..*-x86_64-linux\.iso$
PROXMOX_DEFAULT_ISO_SELECTOR=
# Global create safety switch.
# false = allows creates
# true = blocks creates
PROXMOX_PREVENT_CREATE=false PROXMOX_PREVENT_CREATE=false
# Restrict live creates to a dedicated test scope.
# When true, the PROXMOX_TEST_* values below become required.
PROXMOX_ENABLE_TEST_MODE=false PROXMOX_ENABLE_TEST_MODE=false
# Required only when PROXMOX_ENABLE_TEST_MODE=true.
# Creates are restricted to this node.
PROXMOX_TEST_NODE= PROXMOX_TEST_NODE=
# Required only when PROXMOX_ENABLE_TEST_MODE=true.
# Creates are restricted to this resource pool.
PROXMOX_TEST_POOL= PROXMOX_TEST_POOL=
# Required only when PROXMOX_ENABLE_TEST_MODE=true.
# Automatically added to created VMs in test mode.
PROXMOX_TEST_TAG=codex-e2e PROXMOX_TEST_TAG=codex-e2e
# Required only when PROXMOX_ENABLE_TEST_MODE=true.
# Automatically prefixed to VM names in test mode.
PROXMOX_TEST_VM_NAME_PREFIX=codex-e2e- PROXMOX_TEST_VM_NAME_PREFIX=codex-e2e-
# Reserved for future failed-create cleanup behavior.
# Parsed today, but not yet acted on by the workflow.
PROXMOX_KEEP_FAILED_VM=true PROXMOX_KEEP_FAILED_VM=true
# Request timeout used for API calls and task polling.
PROXMOX_REQUEST_TIMEOUT_SECONDS=15 PROXMOX_REQUEST_TIMEOUT_SECONDS=15

172
README.md
View file

@ -1,25 +1,173 @@
# pve-vm-setup
Textual TUI for creating Proxmox VMs with live reference data, guarded create controls, and a guided multi-step wizard.
## What it does
- logs into a Proxmox VE API endpoint
- loads nodes, pools, storages, bridges, tags, and ISO images from the live cluster
- walks through VM creation in a step-by-step wizard
- can run in a safe read-only mode, a normal live-create mode, or a restricted test mode
## Commands ## Commands
- Install: `uv sync` - Run directly from this repo (without cloning): `uvx git+https://git.s1q.dev/phg/pve-vm-setup.git`
- Run app: `uv run python -m pve_vm_setup` - Install dependencies: `uv sync`
- Run live diagnostics: `uv run python -m pve_vm_setup --doctor-live` - Run the app from the checkout repository: `uv run -m pve_vm_setup`
- Run live diagnostics: `uv run -m pve_vm_setup --doctor-live`
- Run tests: `uv run pytest` - Run tests: `uv run pytest`
- Run read-only live tests: `uv run pytest -m live` - Run read-only live tests: `uv run pytest -m live`
- Run create-gated live tests: `uv run pytest -m live_create` - Run live create tests: `uv run pytest -m live_create`
- Lint: `uv run ruff check .` - Lint: `uv run ruff check .`
- Format: `uv run ruff format .` - Format: `uv run ruff format .`
## Live configuration ## Typical usage
Start from `.env.example` and provide the Proxmox credentials in `.env`. 1. Copy `.env.example` to `.env`.
2. Fill in the Proxmox connection settings.
3. Decide whether this machine should be allowed to create VMs at all.
4. Optionally enable test mode if you want live creates restricted to a known node/pool and auto-tagged.
5. Start the app with `uv run python -m pve_vm_setup`.
6. Log in and complete the wizard.
Additional live-access controls: Before the final create request, the app asks whether the VM should be started automatically after creation.
- `PROXMOX_VERIFY_TLS=false` disables certificate verification for internal/self-signed installs ## Operating modes
- `PROXMOX_API_BASE=/api2/json` makes the API base explicit
- `PROXMOX_PREVENT_CREATE=false` allows VM creation by default; set it to `true` to block creates ### Read-only mode
- `PROXMOX_ENABLE_TEST_MODE=true` enables scoped test mode for live creates
- When test mode is enabled, `PROXMOX_TEST_NODE`, `PROXMOX_TEST_POOL`, `PROXMOX_TEST_TAG`, and `PROXMOX_TEST_VM_NAME_PREFIX` are required and are used to constrain and mark created VMs Use this when you want to browse live data and validate the setup without creating anything.
- Set `PROXMOX_PREVENT_CREATE=true`
- Recommended for first-time setup
- `--doctor-live` is useful here
### Normal live-create mode
Use this when you want to create real VMs without the extra test-mode restrictions.
- Set `PROXMOX_PREVENT_CREATE=false` or leave it unset
- Leave `PROXMOX_ENABLE_TEST_MODE=false`
- Recommended only when you are comfortable with the target cluster and defaults
### Restricted test mode
Use this when you want live creates, but only inside a constrained sandbox.
- Set `PROXMOX_PREVENT_CREATE=false`
- Set `PROXMOX_ENABLE_TEST_MODE=true`
- `PROXMOX_TEST_NODE`, `PROXMOX_TEST_POOL`, `PROXMOX_TEST_TAG`, and `PROXMOX_TEST_VM_NAME_PREFIX` become required
- The app restricts creates to the configured node and pool
- The app automatically adds the configured tag and name prefix
## Environment variables
Start from `.env.example`. The table below describes every supported variable that is currently parsed by the app.
| Variable | Required | Default | Recommended | Purpose |
| --- | --- | --- | --- | --- |
| `PROXMOX_URL` | Required for live access | none | Yes | Base Proxmox URL, for example `https://pve.example.com:8006`. |
| `PROXMOX_REALM` | Required for live access | none | Yes | Proxmox auth realm, for example `pam`, `pve`, or `ldap`. |
| `PROXMOX_USER` | Required for live access | none | Yes | Username used for API login. If it does not contain `@realm`, the configured realm is appended automatically. |
| `PROXMOX_PASSWORD` | Required for live access | none | Yes | Password for the Proxmox user. |
| `PROXMOX_VERIFY_TLS` | Optional | `false` | Yes, if your certificates are valid | Controls TLS certificate verification for API calls. Set to `true` for properly trusted certificates. Set to `false` only for internal or self-signed setups you explicitly trust. |
| `PROXMOX_API_BASE` | Optional | `/api2/json` | Usually leave as-is | API base path appended to `PROXMOX_URL`. Only change this if your deployment needs a different base path. |
| `PROXMOX_REQUEST_TIMEOUT_SECONDS` | Optional | `15` | Usually yes | Request timeout used for API calls and task polling. Increase it if your environment is slow. |
| `PROXMOX_DEFAULT_ISO_SELECTOR` | Optional | unset | Optional | Controls which ISO image is auto-selected in the OS step. Uses glob matching by default. If prefixed with `regex:`, the remainder is treated as a regular expression. |
| `PROXMOX_PREVENT_CREATE` | Optional | `false` | Yes | Global create safety switch. Set to `true` to block VM creation completely. Leave unset or set to `false` to allow creates. |
| `PROXMOX_ENABLE_TEST_MODE` | Optional | `false` | Yes for shared or risky environments | Enables restricted live-create mode. When enabled, the `PROXMOX_TEST_*` scope settings become mandatory. |
| `PROXMOX_TEST_NODE` | Required only in test mode | none | Yes in test mode | Node that live creates are restricted to. |
| `PROXMOX_TEST_POOL` | Required only in test mode | none | Yes in test mode | Pool that live creates are restricted to. |
| `PROXMOX_TEST_TAG` | Required only in test mode | `codex-e2e` | Yes in test mode | Tag added automatically to created VMs in test mode. |
| `PROXMOX_TEST_VM_NAME_PREFIX` | Required only in test mode | `codex-e2e-` | Yes in test mode | Prefix added automatically to VM names in test mode. |
| `PROXMOX_KEEP_FAILED_VM` | Optional | `true` | Leave as-is for now | Parsed by settings, but currently not acted on by the create workflow yet. Treat it as reserved for future cleanup behavior. |
## ISO selector syntax
`PROXMOX_DEFAULT_ISO_SELECTOR` supports two forms:
- Glob syntax, used by default
- Regex syntax, enabled with a `regex:` prefix
Examples:
- `PROXMOX_DEFAULT_ISO_SELECTOR=*ubuntu*`
- `PROXMOX_DEFAULT_ISO_SELECTOR=*debian-12*`
- `PROXMOX_DEFAULT_ISO_SELECTOR=regex:nixos-minimal-\d{2}\.\d{2}\..*-x86_64-linux\.iso$`
Behavior:
- if the selector matches one or more ISOs, the app picks from those matches
- if multiple matching NixOS-style ISOs exist, it prefers the latest one by release naming
- if nothing matches, the app falls back to the built-in default picker
## Recommended `.env` setups
### Safe initial setup
```dotenv
PROXMOX_URL=https://pve.example.com:8006
PROXMOX_REALM=pam
PROXMOX_USER=root
PROXMOX_PASSWORD=replace-me
PROXMOX_VERIFY_TLS=true
PROXMOX_PREVENT_CREATE=true
PROXMOX_ENABLE_TEST_MODE=false
```
### Normal live-create setup
```dotenv
PROXMOX_URL=https://pve.example.com:8006
PROXMOX_REALM=pam
PROXMOX_USER=root
PROXMOX_PASSWORD=replace-me
PROXMOX_VERIFY_TLS=true
PROXMOX_PREVENT_CREATE=false
PROXMOX_ENABLE_TEST_MODE=false
PROXMOX_DEFAULT_ISO_SELECTOR=*nixos*
```
### Restricted test setup
```dotenv
PROXMOX_URL=https://pve.example.com:8006
PROXMOX_REALM=pam
PROXMOX_USER=root
PROXMOX_PASSWORD=replace-me
PROXMOX_VERIFY_TLS=true
PROXMOX_PREVENT_CREATE=false
PROXMOX_ENABLE_TEST_MODE=true
PROXMOX_TEST_NODE=pve-test-01
PROXMOX_TEST_POOL=sandbox
PROXMOX_TEST_TAG=codex-e2e
PROXMOX_TEST_VM_NAME_PREFIX=codex-e2e-
```
## Notes and caveats
- Live access requires `PROXMOX_URL`, `PROXMOX_USER`, `PROXMOX_PASSWORD`, and `PROXMOX_REALM`.
- Test mode is not the same as read-only mode. Test mode still performs real creates when creation is allowed.
- If `PROXMOX_PREVENT_CREATE=true`, the confirm step validates but actual creation is blocked.
- `PROXMOX_TEST_POOL` is currently required when test mode is enabled.
- `PROXMOX_KEEP_FAILED_VM` is currently reserved and not yet implemented in the workflow logic.
## Live diagnostics
Run:
```bash
uv run python -m pve_vm_setup --doctor-live
```
This verifies:
- transport reachability
- API base access
- visible nodes
- configured test node and pool, when test mode is enabled
Use this before enabling creates against a real cluster.
## Engineering rules ## Engineering rules

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import fnmatch
import re import re
from dataclasses import replace from dataclasses import replace
@ -10,6 +11,7 @@ from .settings import AppSettings
_NIXOS_ISO_PATTERN = re.compile( _NIXOS_ISO_PATTERN = re.compile(
r"nixos-minimal-(?P<year>\d{2})[.-](?P<month>\d{2})\.[A-Za-z0-9]+-[A-Za-z0-9_]+-linux\.iso$" r"nixos-minimal-(?P<year>\d{2})[.-](?P<month>\d{2})\.[A-Za-z0-9]+-[A-Za-z0-9_]+-linux\.iso$"
) )
_REGEX_SELECTOR_PREFIX = "regex:"
def select_latest_nixos_iso(isos: list[str]) -> str | None: def select_latest_nixos_iso(isos: list[str]) -> str | None:
@ -23,6 +25,21 @@ def select_latest_nixos_iso(isos: list[str]) -> str | None:
return max(candidates)[2] return max(candidates)[2]
def _matches_iso_selector(iso: str, selector: str) -> bool:
if selector.startswith(_REGEX_SELECTOR_PREFIX):
pattern = selector.removeprefix(_REGEX_SELECTOR_PREFIX)
return re.search(pattern, iso) is not None
return fnmatch.fnmatch(iso, selector)
def select_preferred_iso(isos: list[str], selector: str | None = None) -> str | None:
if selector:
matches = sorted(iso for iso in isos if _matches_iso_selector(iso, selector))
if matches:
return select_latest_nixos_iso(matches) or matches[0]
return select_latest_nixos_iso(isos)
def build_startup_value(order: str, up: str, down: str) -> str: def build_startup_value(order: str, up: str, down: str) -> str:
parts: list[str] = [] parts: list[str] = []
if order.strip(): if order.strip():

View file

@ -12,7 +12,7 @@ from textual.widgets import Button, Checkbox, Input, Select, Static
from ..domain import ( from ..domain import (
build_confirmation_text, build_confirmation_text,
select_latest_nixos_iso, select_preferred_iso,
validate_all_steps, validate_all_steps,
validate_step, validate_step,
) )
@ -55,10 +55,13 @@ class AutoStartConfirmModal(ModalScreen[bool | None]):
#auto-start-actions { #auto-start-actions {
margin-top: 1; margin-top: 1;
height: auto; height: auto;
width: 1fr;
align-horizontal: center;
} }
#auto-start-actions Button { #auto-start-actions Button {
min-width: 8; width: 12;
min-width: 12;
margin-right: 1; margin-right: 1;
} }
""" """
@ -676,7 +679,10 @@ class WizardView(Vertical):
self._workflow.reference_data.isos = iso_values self._workflow.reference_data.isos = iso_values
self._loaded_iso_source = (node, storage) self._loaded_iso_source = (node, storage)
self._set_select_options("os-iso", iso_values) self._set_select_options("os-iso", iso_values)
preferred = select_latest_nixos_iso(iso_values) or (iso_values[0] if iso_values else "") preferred = select_preferred_iso(
iso_values,
self._settings.default_iso_selector,
) or (iso_values[0] if iso_values else "")
if preferred: if preferred:
self.query_one("#os-iso", Select).value = preferred self.query_one("#os-iso", Select).value = preferred
self._workflow.config.os.iso = preferred self._workflow.config.os.iso = preferred

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -82,6 +83,7 @@ class AppSettings:
proxmox_realm: str | None proxmox_realm: str | None
proxmox_verify_tls: bool proxmox_verify_tls: bool
request_timeout_seconds: int request_timeout_seconds: int
default_iso_selector: str | None
safety_policy: LiveSafetyPolicy safety_policy: LiveSafetyPolicy
@classmethod @classmethod
@ -122,6 +124,14 @@ class AppSettings:
proxmox_url = (raw.get("PROXMOX_URL") or "").strip() or None proxmox_url = (raw.get("PROXMOX_URL") or "").strip() or None
if proxmox_url is not None: if proxmox_url is not None:
proxmox_url = proxmox_url.rstrip("/") proxmox_url = proxmox_url.rstrip("/")
default_iso_selector = (raw.get("PROXMOX_DEFAULT_ISO_SELECTOR") or "").strip() or None
if default_iso_selector and default_iso_selector.startswith("regex:"):
try:
re.compile(default_iso_selector.removeprefix("regex:"))
except re.error as exc:
raise SettingsError(
"Invalid PROXMOX_DEFAULT_ISO_SELECTOR regex."
) from exc
return cls( return cls(
proxmox_url=proxmox_url, proxmox_url=proxmox_url,
@ -133,6 +143,7 @@ class AppSettings:
request_timeout_seconds=_parse_int( request_timeout_seconds=_parse_int(
raw.get("PROXMOX_REQUEST_TIMEOUT_SECONDS"), default=15 raw.get("PROXMOX_REQUEST_TIMEOUT_SECONDS"), default=15
), ),
default_iso_selector=default_iso_selector,
safety_policy=safety_policy, safety_policy=safety_policy,
) )

View file

@ -1,4 +1,9 @@
from pve_vm_setup.domain import build_create_payload, select_latest_nixos_iso, validate_all_steps from pve_vm_setup.domain import (
build_create_payload,
select_latest_nixos_iso,
select_preferred_iso,
validate_all_steps,
)
from pve_vm_setup.models.workflow import VmConfig from pve_vm_setup.models.workflow import VmConfig
from pve_vm_setup.settings import AppSettings from pve_vm_setup.settings import AppSettings
@ -15,6 +20,32 @@ def test_select_latest_nixos_iso_prefers_latest_year_month() -> None:
assert choice == "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso" assert choice == "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso"
def test_select_preferred_iso_uses_glob_selector_when_configured() -> None:
choice = select_preferred_iso(
[
"cephfs:iso/debian-12.iso",
"cephfs:iso/nixos-minimal-24.11.1234abcd-x86_64-linux.iso",
"cephfs:iso/ubuntu-24.04.iso",
],
"*ubuntu*",
)
assert choice == "cephfs:iso/ubuntu-24.04.iso"
def test_select_preferred_iso_uses_regex_selector_when_prefixed() -> None:
choice = select_preferred_iso(
[
"cephfs:iso/debian-12.iso",
"cephfs:iso/nixos-minimal-24.11.1234abcd-x86_64-linux.iso",
"cephfs:iso/nixos-graphical-25.05.iso",
],
r"regex:nixos-graphical-\d{2}\.\d{2}\.iso$",
)
assert choice == "cephfs:iso/nixos-graphical-25.05.iso"
def test_build_create_payload_applies_safety_name_tag_and_key_settings() -> None: def test_build_create_payload_applies_safety_name_tag_and_key_settings() -> None:
settings = AppSettings.from_env( settings = AppSettings.from_env(
{ {

View file

@ -20,6 +20,7 @@ def test_settings_load_defaults_and_normalize_api_base() -> None:
assert settings.proxmox_api_base == "/api2/json" assert settings.proxmox_api_base == "/api2/json"
assert settings.proxmox_verify_tls is False assert settings.proxmox_verify_tls is False
assert settings.request_timeout_seconds == 15 assert settings.request_timeout_seconds == 15
assert settings.default_iso_selector is None
assert settings.effective_username == "root@pam" assert settings.effective_username == "root@pam"
assert settings.safety_policy.prevent_create is False assert settings.safety_policy.prevent_create is False
assert settings.safety_policy.enable_test_mode is False assert settings.safety_policy.enable_test_mode is False
@ -54,3 +55,13 @@ def test_settings_allow_create_by_default_when_prevent_flag_is_unset() -> None:
assert settings.safety_policy.prevent_create is False assert settings.safety_policy.prevent_create is False
assert settings.safety_policy.allow_create is True assert settings.safety_policy.allow_create is True
def test_settings_reject_invalid_default_iso_regex_selector() -> None:
with pytest.raises(SettingsError):
AppSettings.from_env(
{
"PROXMOX_DEFAULT_ISO_SELECTOR": "regex:[unterminated",
},
load_dotenv_file=False,
)