Refactor Proxmox settings validation and enhance dotenv loading logic; update README for clarity on configuration requirements

This commit is contained in:
Philip Henning 2026-03-08 19:25:45 +01:00
parent 9a7bd81d17
commit 376e6f5631
10 changed files with 135 additions and 25 deletions

View file

@ -2,16 +2,19 @@
# Required for live API access. # Required for live API access.
PROXMOX_URL=https://proxmox.example.invalid:8006 PROXMOX_URL=https://proxmox.example.invalid:8006
# Login realm used for authentication. # Default login realm used for authentication.
# Required for live API access. # Optional for the interactive app, because realms can be loaded and selected manually.
# Still required for non-interactive doctor login checks.
PROXMOX_REALM=pam PROXMOX_REALM=pam
# Proxmox username. If it does not already include @realm, the app appends PROXMOX_REALM. # Default Proxmox username. If it does not already include @realm, the app appends PROXMOX_REALM.
# Required for live API access. # Optional for the interactive app, because it can be entered manually.
# Still required for non-interactive doctor login checks.
PROXMOX_USER=root PROXMOX_USER=root
# Password for the configured user. # Default password for the configured user.
# Required for live API access. # Optional for the interactive app, because it can be entered manually.
# Still required for non-interactive doctor login checks.
PROXMOX_PASSWORD=replace-me PROXMOX_PASSWORD=replace-me
# Verify TLS certificates for API requests. # Verify TLS certificates for API requests.

View file

@ -23,7 +23,7 @@ Textual TUI for creating Proxmox VMs with live reference data, guarded create co
## Typical usage ## Typical usage
1. Copy `.env.example` to `.env`. 1. Copy `.env.example` to `~/.config/pve-vm-setup/.env` or `.env`.
2. Fill in the Proxmox connection settings. 2. Fill in the Proxmox connection settings.
3. Decide whether this machine should be allowed to create VMs at all. 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. 4. Optionally enable test mode if you want live creates restricted to a known node/pool and auto-tagged.
@ -64,12 +64,20 @@ Use this when you want live creates, but only inside a constrained sandbox.
Start from `.env.example`. The table below describes every supported variable that is currently parsed by the app. Start from `.env.example`. The table below describes every supported variable that is currently parsed by the app.
Configuration source order:
1. Process environment variables
2. `~/.config/pve-vm-setup/.env`
3. `.env` in the current working directory
Higher entries override lower ones.
| Variable | Required | Default | Recommended | Purpose | | Variable | Required | Default | Recommended | Purpose |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `PROXMOX_URL` | Required for live access | none | Yes | Base Proxmox URL, for example `https://pve.example.com:8006`. | | `PROXMOX_URL` | Required for live access | none | Yes | Base Proxmox URL, for example `https://pve.example.com:8006`. This is the only setting required to make the interactive app use the real Proxmox backend instead of fake mode. |
| `PROXMOX_REALM` | Required for live access | none | Yes | Proxmox auth realm, for example `pam`, `pve`, or `ldap`. | | `PROXMOX_REALM` | Optional | none | Recommended | Default realm for login. If unset, the login view loads realms from Proxmox and lets you choose one manually. Still required for non-interactive doctor login checks. |
| `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_USER` | Optional | none | Recommended | Default username shown in the login form. If unset, you can type it manually. Still required for non-interactive doctor login checks. |
| `PROXMOX_PASSWORD` | Required for live access | none | Yes | Password for the Proxmox user. | | `PROXMOX_PASSWORD` | Optional | none | Usually no | Default password shown in the login form. If unset, you can type it manually. Still required for non-interactive doctor login checks. |
| `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_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_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_REQUEST_TIMEOUT_SECONDS` | Optional | `15` | Usually yes | Request timeout used for API calls and task polling. Increase it if your environment is slow. |
@ -146,7 +154,9 @@ PROXMOX_TEST_VM_NAME_PREFIX=codex-e2e-
## Notes and caveats ## Notes and caveats
- Live access requires `PROXMOX_URL`, `PROXMOX_USER`, `PROXMOX_PASSWORD`, and `PROXMOX_REALM`. - Interactive live access only requires `PROXMOX_URL`.
- `PROXMOX_USER`, `PROXMOX_PASSWORD`, and `PROXMOX_REALM` act as login defaults for the UI.
- `--doctor-live` still requires `PROXMOX_URL`, `PROXMOX_USER`, `PROXMOX_PASSWORD`, and `PROXMOX_REALM` because it performs a full non-interactive login.
- Test mode is not the same as read-only mode. Test mode still performs real creates when creation is allowed. - 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. - 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_TEST_POOL` is currently required when test mode is enabled.

View file

@ -45,7 +45,7 @@ class ProxmoxApiClient:
transport: httpx.BaseTransport | None = None, transport: httpx.BaseTransport | None = None,
client_factory: Callable[..., httpx.Client] | None = None, client_factory: Callable[..., httpx.Client] | None = None,
) -> None: ) -> None:
settings.validate_live_requirements() settings.validate_live_endpoint_requirements()
self._settings = settings self._settings = settings
factory = client_factory or httpx.Client factory = client_factory or httpx.Client
self._client = factory( self._client = factory(

View file

@ -12,6 +12,14 @@ from dotenv import dotenv_values
from .errors import SettingsError from .errors import SettingsError
def _load_dotenv(path: Path) -> dict[str, str]:
return {
key: value
for key, value in dotenv_values(path).items()
if value is not None
}
def _parse_bool(value: str | None, *, default: bool) -> bool: def _parse_bool(value: str | None, *, default: bool) -> bool:
if value is None or value == "": if value is None or value == "":
return default return default
@ -93,16 +101,18 @@ class AppSettings:
*, *,
load_dotenv_file: bool = True, load_dotenv_file: bool = True,
dotenv_path: str | Path = ".env", dotenv_path: str | Path = ".env",
config_dotenv_path: str | Path | None = None,
) -> AppSettings: ) -> AppSettings:
raw: dict[str, str] = {} raw: dict[str, str] = {}
if load_dotenv_file: if load_dotenv_file:
raw.update( cwd_dotenv = Path(dotenv_path)
{ home_config_dotenv = (
key: value Path(config_dotenv_path).expanduser()
for key, value in dotenv_values(dotenv_path).items() if config_dotenv_path is not None
if value is not None else Path.home() / ".config" / "pve-vm-setup" / ".env"
}
) )
raw.update(_load_dotenv(cwd_dotenv))
raw.update(_load_dotenv(home_config_dotenv))
raw.update(os.environ if env is None else env) raw.update(os.environ if env is None else env)
api_base = raw.get("PROXMOX_API_BASE", "/api2/json").strip() or "/api2/json" api_base = raw.get("PROXMOX_API_BASE", "/api2/json").strip() or "/api2/json"
@ -149,7 +159,7 @@ class AppSettings:
@property @property
def is_live_configured(self) -> bool: def is_live_configured(self) -> bool:
return bool(self.proxmox_url and self.proxmox_user and self.proxmox_password) return bool(self.proxmox_url)
@property @property
def effective_username(self) -> str | None: def effective_username(self) -> str | None:
@ -176,9 +186,8 @@ class AppSettings:
return f"{self.proxmox_url}{self.proxmox_api_base}" return f"{self.proxmox_url}{self.proxmox_api_base}"
def validate_live_requirements(self) -> None: def validate_live_requirements(self) -> None:
self.validate_live_endpoint_requirements()
missing: list[str] = [] missing: list[str] = []
if not self.proxmox_url:
missing.append("PROXMOX_URL")
if not self.proxmox_user: if not self.proxmox_user:
missing.append("PROXMOX_USER") missing.append("PROXMOX_USER")
if not self.proxmox_password: if not self.proxmox_password:
@ -188,3 +197,7 @@ class AppSettings:
if missing: if missing:
joined = ", ".join(missing) joined = ", ".join(missing)
raise SettingsError(f"Missing live Proxmox configuration: {joined}.") raise SettingsError(f"Missing live Proxmox configuration: {joined}.")
def validate_live_endpoint_requirements(self) -> None:
if not self.proxmox_url:
raise SettingsError("Missing live Proxmox configuration: PROXMOX_URL.")

View file

@ -16,9 +16,6 @@ def test_factory_returns_live_service_when_live_env_is_present() -> None:
settings = AppSettings.from_env( settings = AppSettings.from_env(
{ {
"PROXMOX_URL": "https://proxmox.example.invalid:8006", "PROXMOX_URL": "https://proxmox.example.invalid:8006",
"PROXMOX_USER": "root",
"PROXMOX_PASSWORD": "secret",
"PROXMOX_REALM": "pam",
}, },
load_dotenv_file=False, load_dotenv_file=False,
) )

View file

@ -1,3 +1,5 @@
from pathlib import Path
import pytest import pytest
from pve_vm_setup.errors import SettingsError from pve_vm_setup.errors import SettingsError
@ -57,6 +59,29 @@ def test_settings_allow_create_by_default_when_prevent_flag_is_unset() -> None:
assert settings.safety_policy.allow_create is True assert settings.safety_policy.allow_create is True
def test_settings_treat_url_only_as_live_capable_for_interactive_login() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006",
},
load_dotenv_file=False,
)
assert settings.is_live_configured is True
def test_settings_validate_live_requirements_still_needs_login_defaults_for_doctor() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006",
},
load_dotenv_file=False,
)
with pytest.raises(SettingsError):
settings.validate_live_requirements()
def test_settings_reject_invalid_default_iso_regex_selector() -> None: def test_settings_reject_invalid_default_iso_regex_selector() -> None:
with pytest.raises(SettingsError): with pytest.raises(SettingsError):
AppSettings.from_env( AppSettings.from_env(
@ -65,3 +90,65 @@ def test_settings_reject_invalid_default_iso_regex_selector() -> None:
}, },
load_dotenv_file=False, load_dotenv_file=False,
) )
def test_settings_loads_current_directory_dotenv_when_present(tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / ".env").write_text(
"PROXMOX_URL=https://cwd.example.invalid:8006\n",
encoding="utf-8",
)
settings = AppSettings.from_env({}, load_dotenv_file=True)
assert settings.proxmox_url == "https://cwd.example.invalid:8006"
def test_settings_prefers_config_dotenv_over_current_directory_dotenv(
tmp_path: Path, monkeypatch
) -> None:
home = tmp_path / "home"
config_dir = home / ".config" / "pve-vm-setup"
config_dir.mkdir(parents=True)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HOME", str(home))
(tmp_path / ".env").write_text(
"PROXMOX_URL=https://cwd.example.invalid:8006\n",
encoding="utf-8",
)
(config_dir / ".env").write_text(
"PROXMOX_URL=https://config.example.invalid:8006\n",
encoding="utf-8",
)
settings = AppSettings.from_env({}, load_dotenv_file=True)
assert settings.proxmox_url == "https://config.example.invalid:8006"
def test_settings_prefers_environment_over_config_and_current_directory_dotenv(
tmp_path: Path, monkeypatch
) -> None:
home = tmp_path / "home"
config_dir = home / ".config" / "pve-vm-setup"
config_dir.mkdir(parents=True)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HOME", str(home))
(tmp_path / ".env").write_text(
"PROXMOX_URL=https://cwd.example.invalid:8006\n",
encoding="utf-8",
)
(config_dir / ".env").write_text(
"PROXMOX_URL=https://config.example.invalid:8006\n",
encoding="utf-8",
)
settings = AppSettings.from_env(
{
"PROXMOX_URL": "https://env.example.invalid:8006",
},
load_dotenv_file=True,
)
assert settings.proxmox_url == "https://env.example.invalid:8006"