From 9a7bd81d17deda863a6c91c79869f7778b5cba23 Mon Sep 17 00:00:00 2001 From: phg Date: Sun, 8 Mar 2026 18:56:58 +0100 Subject: [PATCH] Enhance ISO selection logic and update documentation for improved clarity and usage instructions --- .env.example | 47 +++++ README.md | 172 ++++++++++++++++-- .../__pycache__/domain.cpython-313.pyc | Bin 20844 -> 22010 bytes .../__pycache__/settings.cpython-313.pyc | Bin 9228 -> 9895 bytes src/pve_vm_setup/domain.py | 17 ++ .../__pycache__/wizard.cpython-313.pyc | Bin 65892 -> 66057 bytes src/pve_vm_setup/screens/wizard.py | 12 +- src/pve_vm_setup/settings.py | 11 ++ .../test_domain.cpython-313-pytest-8.4.2.pyc | Bin 9131 -> 10750 bytes ...test_settings.cpython-313-pytest-8.4.2.pyc | Bin 11190 -> 12231 bytes tests/test_domain.py | 33 +++- tests/test_settings.py | 11 ++ 12 files changed, 287 insertions(+), 16 deletions(-) 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 dc4f1a35154981c1bbef87452edc3229d475d0f1..1d78808f55d8ac53b363c9d3aca9729b98de523f 100644 GIT binary patch delta 3119 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 b$at4Q>G5QvP-XTnTpWyYU&JQoh4ugdmKC;f diff --git a/src/pve_vm_setup/__pycache__/settings.cpython-313.pyc b/src/pve_vm_setup/__pycache__/settings.cpython-313.pyc index eac1a8c959bc11a682b91f87aad4f89f2a5a98e5..da9bd3d35306f3a661c2531fec3ce1b49c846879 100644 GIT binary patch delta 1523 zcmeD2SnkXBnU|M~fq{X6Y0KKo9I1(X67?q-7#OB91TzFPdNX)27BMP-*i7C`MNGjA z!A#!FMa;nr!OY$)UaUo|U^y0VHZS%fb}*aOo5PE1@z7=nd@xuh7fSc(LKg@d`lEWsE?_KEY9nT2B5CmvB|6yEq^J|ko3 zO*`Ft>o+*YUm{*;FfgzYLh9#IE#EM~+V+fWG77P{& z7S>~uV?c(%GQlDshh;HkF`9zHiGhKkh%JUaST8WPK|*sSss82||XRv>K zu&a-&bBKRX63Sh;0ldD9s7&#|D713ZX3T0qm&=i`? zFY0c8i>WB}7JG7jZb4>F>Mfq4)ZF~C)Pkbaw9JZItf@ss`9(#btXKr{hbGT0=H#5> zTg<6>Wkq42L>E3ePgGV4WL%Lih#vwX6c`v7ig_3q7!(vH&lJ^Xiv;mkZ$2kFfidca zg7R|qh3pqJ+%7A)Usv!wVRT(SxaeGl>g55SRME$|NZL z`vWsGuk^2CB?bnDjT}A0CVX}h64aRAke@etKGS1o@CiRPvana^LnN&u`HJi7~9%U3r0)4|wXa21GP~h)xjE10tqO zo~x+H1`7G&+mnwd8iY%!TxLoYS0o8Si~kZi?dSEWUgBDc8Wfd7#OB91TzFPdNX)27BMP-*i7C`MNGjA z!OY&wUMxi{U@;bNRxh?9HZYsjo860}hy%=K^XBy8D&hjO*}b{Fc#3!w7=k&1IlXzk z_=@-x7=nd@xuh7fSc>?Ag@d`lEP)s{C5B+0U|!3KYh{=PW7sC1QDzj{_+dUHW8mZo zOt$Qm`K3kii3O8yFv&CWZT`U&!pJDO*_TCykx^-L5vwpWqt50w4qiq^hs{$str;0z zHecY9U}RL9{F1wn@zdmJo~U}>7?xl@bp{587*;ukV5wk%V8LJ^JtjE@WEd0ZDJWbR7#ND!V%URaf<-{$a50V;_F&myQ4}%G81`VfU@;UiF0h!m6oYOS3tTgI zuzautNLdk2uw)T$I=`mEEpEqxg5cDWlFYpH;+H;?vw0^?7Um0PQexel!MB~6ok58q zlrxll@&aM;$)-Y{AevdklyPzczxd?&!V=7)P`1C2{v;7aK`~QCi0gw{gV`*Zq9rFg z2+FI9!$l=PqM;nYl9n7Q48=?e48_cOY%fP@;3`%5^yTtX`!a+Rs&HKeCFfu>n7n;0MvQ<%9LX+K<~HdVM#lclk7a%_GEU!oPcD^_ap7hcg`jMXalG+U=jgQ=htXvOxRbhhC z@*g-s(p(H;3LijnA9xtJM8EPHvT}W9W?&Qizyy*_n|woMk%;guuK4)0(vs4m)cE)! zW{}NUlP9ZA5u3rlzyONC;%o*6h7Zh)jEr{~lparZQ1g;aV`L0s{J;RFJ~J~gNql4i Kvx{UH7#IL?Sl\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 f22034cd3826c25634dbcc6dce63eff57a8d0c47..e7c9ea4aaa4d9697f69dda54f83c8a14684e675c 100644 GIT binary patch delta 2827 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 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 99b4bfe06411e6a2ab92edabb43c07055bd982f5..ec193c024b8490cd647fe210eb9125f80f0236ff 100644 GIT binary patch delta 2162 zcmZ4O{x6vCGcPX}0|Ntt@s_oj_JR}nBp9bmR5z^m;ws`&U?dE$>*6O)P6}Mrxs+S6{m7t9vT24OKU1aVAmWEYuyT~u)LVa6mz zw#h#j6~!4%q2>m&>oMuFF)-vY2Xh2-PL5*=tmpF6WG>=iU|=W$h5avSB(IbvmFAU{ z>Kd8o8JK{4Q^lbL5z;DB2Wj915&R%R07M9a2oVq=3i35)5i5u-1|q~kgan9?1QAjo zLK;NK)PqP_5Fy9Fz)%d*q5uL#$_xw)w`@vMi%a6s13x~qI6uC$IJG!FJtsd2DxF^x zU!IYg7oVJ;mzJ4cT9lenq{+a*0J6Ebl!1Yvf#D9X;wL5+POcAJ44lHBdD;2c8r(i4 z2r-BmUtlr1AZm1x+vqwA6kTF5>M(=zAdIUhViys-t6=RAMabkO7Na87$#JZbgC=%W zkYYD4vm(D(H@zsaAR{w5F-O!LC4yT~G$htVm5M(qUj=0OcMmaccsLTOo!xF`El4))z#r zFLGO7XMv(iEY_g7h4LW6S5d?+B6wH9+98UN$xAHOMXH!;niXcJ>l*K^~0OfE61%)Ez$v?y`S(6zU7y>7o zNjzo*=a$XJl5WiPU?1z@^syDlJczeJEU*v23DFkBwF423Ai@bmID-gp5D^S=0Vpt_ zE>M7!GhX1Nh+OPX{vl(LG#IGLLQTO<-DLyEtIojBIHzr zE8Rg(02Sy(xSXmfM0!-Y-eSv5&CM^Wyv3QAlapT_pIw|=#0;_uE#lxYRfHuNCg&@f zvSu&P3aWUF_k&j`qiPYz5&+=u B>(KxJ delta 1109 zcmewtyxN`bGcPX}0|Ntt?CQ0dEPNCBBp7QZsvAZrFa$FMGkbG+aTjre*(}~XUc5!T z3Jk$q!K_jYSu90-!Cb*?U>1KcS1>!6B@oON%mHQz26F{-f>}c8jGEjpL3a3QGT#!J zte`A1@t)LXKE}(8j8&6mn58HGWr|R%l1NT1$Ve-;$}G;;Pf1P6Ow7|YG}6l~&M)F* zU|?9u_>0NbR&#PSvn-?Lh{*>c_(6mK0|SGmND;_gMfM;8 zHW1+eA{;@46Nmsi&>6&X0THesf)(TwmXgYX)FL!T3xlLZK!hlWfcODq4SSJ0gB}Az zkq1aP1Vng&2uTnj4I*S17#NB{PEmk>BH770a+a)N3=9lLle6R=Gs;h%z#+LgUEYnk z9_(X9oIchCnFsMUhz0fmD6th8fVhSr!URN^f(SDZVFx1IKrY~5U|@haKtZ8M7UWN_ zlGNgo_@vUzoRs+FqSVBa)XD$EI47Gb8?r_)Ffh1o&Q?}ngt~62iWL(f*C8Bc1+tY1 zMBs9rrV#0|GdWIO(hx0L;PFs|!;|`x--=01KBO+j8pFWA5HqnllbfGXnv-f*l*+)szz7O_Q2PA9%*e=ipMjx;;W|U&MTW$? p42E|Zbic51G6pbyk&tC%`{KYT!06BTMNNoN?W>j`qiPYzOaP=t*PZ|X 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 7c12438d5bcd3cf6bfad86b1d62ac590c3440e97..81e221aefac1dcf89b844d782ecb546dade65ee5 100644 GIT binary patch delta 1065 zcmdlMemtJPIg9y$qg*ljAEMwm?Ie{JM#;(;!bCj+`Nuok&#zQk42Y_fgz7ISUOl{@*RONmQaQug~>s@+LPyU z+AzxERpr93%_ujyfZvu;9)~&ukZB-KqT+nPAPrOz5HF88SOILBB6iav8I?Ah2?{Z> zXfjsWOcoT9mP|=aODxSPiO(#~k1tNmNlh-vFXF0I$SmIMAk4wUC_33w^tPx7HdULI z#WEPVZ5S9B*cliYikVn9PZMWms@J&4t#N_HV1)~W;5N9<0!5ct3@&hMz_}2{RThH| z3kdT%i@`+%?<$h&%OLaMir|dPECx5Yg|Bm~UF24~z+$pO>jI0(=B1J;%#151bIB^R z*)lLN6j@I;mbGSeWnf^CpIj_!&-i)sX4w}kjCzyjC|Q{sFfcHzWG@l~DKrKVrXa!s zL|B3da}Z$#@*c>K3J_3aJ(*Y8o7IJrJSKz`!tht&*m=H;CthX{Lv!8>>G91B2=2$(p*1jJA_CWp>xg1O)j<`1(i0 zySTbJhWdoWdj|W*2fO;XI*0fNRS6fRrl(d|MVIE4q!#67<|US-rnnXHfh=$Y5iTGC zBaFysadJXCGnYgWr;bNDe=g03^q5uI8_jmAc}$* z7#Kh?U2HMALRQ95?}CBr2WBQ-z7K2+a>}1LSvk4BGc$0hUFTH2#;N+N$PE+%Ot;vI zQcFsU^87RfCSTNYVRV`-rfpRKGNuR=9Yvr3ECTr$YxVc^HWN5QtgVO7#J8B zLE)vtz`*cezb%~aGcPX}0|Ntt?CQ0d8SE4JBpA~ssyk?N1hYsnWU&-+2Xh3of>}Jl9Kmd0 z7H=>|FguvVm(HlkIdP5+QX4D1XH48{E{n-@tk zGi{cTDPU&QoZKm|%x1&Dz))m0d6T>~moozcgB(bi`Q&arnaRQma*V~BjTBz6FzQa0 zR<$zMXJBAh$zCJ~(qRN5OhAM=h_C<=W+1{6WB|xA1qdjzn%tx6&FaO#z@V}Dj;akK z3&_UF_6lN~jnu7Kq}&-87&Li{SU`H^L4+=d&|_d=n5?6!DdPd+d19E!z`$_JVe&*( z>B-Nv)mVKP7#K`83+d=G3W5yRWV*#xlv+|+l;@|(Klz593!}qiS$(TCP%spMJXr*C zO%X^}krT*#kftIlkeM7dx%nxjIjMF`Ht}h~-j5c2-SQyQVKn4K-W?q5N 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, + )