diff --git a/.env.example b/.env.example index be671eb..482aa68 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index 7d1012c..5ed14a7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/pve_vm_setup/__pycache__/settings.cpython-313.pyc b/src/pve_vm_setup/__pycache__/settings.cpython-313.pyc index da9bd3d..f6bac1b 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/services/__pycache__/proxmox.cpython-313.pyc b/src/pve_vm_setup/services/__pycache__/proxmox.cpython-313.pyc index 3ae6490..b84dc71 100644 Binary files a/src/pve_vm_setup/services/__pycache__/proxmox.cpython-313.pyc and b/src/pve_vm_setup/services/__pycache__/proxmox.cpython-313.pyc differ diff --git a/src/pve_vm_setup/services/proxmox.py b/src/pve_vm_setup/services/proxmox.py index 7c8a305..74255f9 100644 --- a/src/pve_vm_setup/services/proxmox.py +++ b/src/pve_vm_setup/services/proxmox.py @@ -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( diff --git a/src/pve_vm_setup/settings.py b/src/pve_vm_setup/settings.py index 972f65e..9e9a17c 100644 --- a/src/pve_vm_setup/settings.py +++ b/src/pve_vm_setup/settings.py @@ -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.") diff --git a/tests/__pycache__/test_factory.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_factory.cpython-313-pytest-8.4.2.pyc index 1fb5b0c..593cac0 100644 Binary files a/tests/__pycache__/test_factory.cpython-313-pytest-8.4.2.pyc and b/tests/__pycache__/test_factory.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 81e221a..db64f30 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_factory.py b/tests/test_factory.py index e5fb38d..55ee220 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -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, ) diff --git a/tests/test_settings.py b/tests/test_settings.py index ec9788d..4dc58fc 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -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"