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.
PROXMOX_URL=https://proxmox.example.invalid:8006
# Login realm used for authentication.
# Required for live API access.
# Default login realm used for authentication.
# 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 username. If it does not already include @realm, the app appends PROXMOX_REALM.
# Required for live API access.
# Default Proxmox username. If it does not already include @realm, the app appends PROXMOX_REALM.
# Optional for the interactive app, because it can be entered manually.
# Still required for non-interactive doctor login checks.
PROXMOX_USER=root
# Password for the configured user.
# Required for live API access.
# Default password for the configured user.
# Optional for the interactive app, because it can be entered manually.
# Still required for non-interactive doctor login checks.
PROXMOX_PASSWORD=replace-me
# 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
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.
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.
@ -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.
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 |
| --- | --- | --- | --- | --- |
| `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_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` | 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` | 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` | 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_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. |
@ -146,7 +154,9 @@ PROXMOX_TEST_VM_NAME_PREFIX=codex-e2e-
## 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.
- 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.

View file

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

View file

@ -12,6 +12,14 @@ from dotenv import dotenv_values
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:
if value is None or value == "":
return default
@ -93,16 +101,18 @@ class AppSettings:
*,
load_dotenv_file: bool = True,
dotenv_path: str | Path = ".env",
config_dotenv_path: str | Path | None = None,
) -> AppSettings:
raw: dict[str, str] = {}
if load_dotenv_file:
raw.update(
{
key: value
for key, value in dotenv_values(dotenv_path).items()
if value is not None
}
cwd_dotenv = Path(dotenv_path)
home_config_dotenv = (
Path(config_dotenv_path).expanduser()
if config_dotenv_path 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)
api_base = raw.get("PROXMOX_API_BASE", "/api2/json").strip() or "/api2/json"
@ -149,7 +159,7 @@ class AppSettings:
@property
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
def effective_username(self) -> str | None:
@ -176,9 +186,8 @@ class AppSettings:
return f"{self.proxmox_url}{self.proxmox_api_base}"
def validate_live_requirements(self) -> None:
self.validate_live_endpoint_requirements()
missing: list[str] = []
if not self.proxmox_url:
missing.append("PROXMOX_URL")
if not self.proxmox_user:
missing.append("PROXMOX_USER")
if not self.proxmox_password:
@ -188,3 +197,7 @@ class AppSettings:
if missing:
joined = ", ".join(missing)
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(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006",
"PROXMOX_USER": "root",
"PROXMOX_PASSWORD": "secret",
"PROXMOX_REALM": "pam",
},
load_dotenv_file=False,
)

View file

@ -1,3 +1,5 @@
from pathlib import Path
import pytest
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
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:
with pytest.raises(SettingsError):
AppSettings.from_env(
@ -65,3 +90,65 @@ def test_settings_reject_invalid_default_iso_regex_selector() -> None:
},
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"