diff --git a/.env.example b/.env.example index f23e614..be671eb 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,61 @@ +# Base Proxmox endpoint, including scheme and port. +# Required for live API access. PROXMOX_URL=https://proxmox.example.invalid:8006 + +# Login realm used for authentication. +# Required for live API access. 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 + +# Password for the configured user. +# Required for live API access. 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 + +# Usually leave this at the default Proxmox API base. 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 + +# Restrict live creates to a dedicated test scope. +# When true, the PROXMOX_TEST_* values below become required. PROXMOX_ENABLE_TEST_MODE=false + +# Required only when PROXMOX_ENABLE_TEST_MODE=true. +# Creates are restricted to this node. PROXMOX_TEST_NODE= + +# Required only when PROXMOX_ENABLE_TEST_MODE=true. +# Creates are restricted to this resource 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 + +# Required only when PROXMOX_ENABLE_TEST_MODE=true. +# Automatically prefixed to VM names in test mode. 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 + +# Request timeout used for API calls and task polling. PROXMOX_REQUEST_TIMEOUT_SECONDS=15 diff --git a/README.md b/README.md index 7218a67..7d1012c 100644 --- a/README.md +++ b/README.md @@ -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 -- Install: `uv sync` -- Run app: `uv run python -m pve_vm_setup` -- Run live diagnostics: `uv run python -m pve_vm_setup --doctor-live` +- Run directly from this repo (without cloning): `uvx git+https://git.s1q.dev/phg/pve-vm-setup.git` +- Install dependencies: `uv sync` +- 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 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 .` - 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 -- `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 -- `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 +## Operating modes + +### Read-only mode + +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 diff --git a/src/pve_vm_setup/__pycache__/domain.cpython-313.pyc b/src/pve_vm_setup/__pycache__/domain.cpython-313.pyc index dc4f1a3..1d78808 100644 Binary files a/src/pve_vm_setup/__pycache__/domain.cpython-313.pyc and b/src/pve_vm_setup/__pycache__/domain.cpython-313.pyc differ diff --git a/src/pve_vm_setup/__pycache__/settings.cpython-313.pyc b/src/pve_vm_setup/__pycache__/settings.cpython-313.pyc index eac1a8c..da9bd3d 100644 Binary files a/src/pve_vm_setup/__pycache__/settings.cpython-313.pyc and b/src/pve_vm_setup/__pycache__/settings.cpython-313.pyc differ diff --git a/src/pve_vm_setup/domain.py b/src/pve_vm_setup/domain.py index 507f4a7..3b8272c 100644 --- a/src/pve_vm_setup/domain.py +++ b/src/pve_vm_setup/domain.py @@ -1,5 +1,6 @@ from __future__ import annotations +import fnmatch import re from dataclasses import replace @@ -10,6 +11,7 @@ from .settings import AppSettings _NIXOS_ISO_PATTERN = re.compile( r"nixos-minimal-(?P\d{2})[.-](?P\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: @@ -23,6 +25,21 @@ def select_latest_nixos_iso(isos: list[str]) -> str | None: 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: parts: list[str] = [] if order.strip(): diff --git a/src/pve_vm_setup/screens/__pycache__/wizard.cpython-313.pyc b/src/pve_vm_setup/screens/__pycache__/wizard.cpython-313.pyc index f22034c..e7c9ea4 100644 Binary files a/src/pve_vm_setup/screens/__pycache__/wizard.cpython-313.pyc and b/src/pve_vm_setup/screens/__pycache__/wizard.cpython-313.pyc differ diff --git a/src/pve_vm_setup/screens/wizard.py b/src/pve_vm_setup/screens/wizard.py index 356e8bb..6ec689f 100644 --- a/src/pve_vm_setup/screens/wizard.py +++ b/src/pve_vm_setup/screens/wizard.py @@ -12,7 +12,7 @@ from textual.widgets import Button, Checkbox, Input, Select, Static from ..domain import ( build_confirmation_text, - select_latest_nixos_iso, + select_preferred_iso, validate_all_steps, validate_step, ) @@ -55,10 +55,13 @@ class AutoStartConfirmModal(ModalScreen[bool | None]): #auto-start-actions { margin-top: 1; height: auto; + width: 1fr; + align-horizontal: center; } #auto-start-actions Button { - min-width: 8; + width: 12; + min-width: 12; margin-right: 1; } """ @@ -676,7 +679,10 @@ class WizardView(Vertical): self._workflow.reference_data.isos = iso_values self._loaded_iso_source = (node, storage) 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: self.query_one("#os-iso", Select).value = preferred self._workflow.config.os.iso = preferred diff --git a/src/pve_vm_setup/settings.py b/src/pve_vm_setup/settings.py index d3171e0..972f65e 100644 --- a/src/pve_vm_setup/settings.py +++ b/src/pve_vm_setup/settings.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path @@ -82,6 +83,7 @@ class AppSettings: proxmox_realm: str | None proxmox_verify_tls: bool request_timeout_seconds: int + default_iso_selector: str | None safety_policy: LiveSafetyPolicy @classmethod @@ -122,6 +124,14 @@ class AppSettings: proxmox_url = (raw.get("PROXMOX_URL") or "").strip() or None if proxmox_url is not None: 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( proxmox_url=proxmox_url, @@ -133,6 +143,7 @@ class AppSettings: request_timeout_seconds=_parse_int( raw.get("PROXMOX_REQUEST_TIMEOUT_SECONDS"), default=15 ), + default_iso_selector=default_iso_selector, safety_policy=safety_policy, ) diff --git a/tests/__pycache__/test_domain.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_domain.cpython-313-pytest-8.4.2.pyc index 99b4bfe..ec193c0 100644 Binary files a/tests/__pycache__/test_domain.cpython-313-pytest-8.4.2.pyc and b/tests/__pycache__/test_domain.cpython-313-pytest-8.4.2.pyc differ diff --git a/tests/__pycache__/test_settings.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_settings.cpython-313-pytest-8.4.2.pyc index 7c12438..81e221a 100644 Binary files a/tests/__pycache__/test_settings.cpython-313-pytest-8.4.2.pyc and b/tests/__pycache__/test_settings.cpython-313-pytest-8.4.2.pyc differ diff --git a/tests/test_domain.py b/tests/test_domain.py index 71ebb31..53a6026 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -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.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" +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: settings = AppSettings.from_env( { diff --git a/tests/test_settings.py b/tests/test_settings.py index 98c5af3..ec9788d 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -20,6 +20,7 @@ def test_settings_load_defaults_and_normalize_api_base() -> None: assert settings.proxmox_api_base == "/api2/json" assert settings.proxmox_verify_tls is False assert settings.request_timeout_seconds == 15 + assert settings.default_iso_selector is None assert settings.effective_username == "root@pam" assert settings.safety_policy.prevent_create 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.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, + )