diff --git a/src/pve_vm_setup/app.py b/src/pve_vm_setup/app.py index 1e22cb9..78b9b2a 100644 --- a/src/pve_vm_setup/app.py +++ b/src/pve_vm_setup/app.py @@ -1,15 +1,19 @@ from __future__ import annotations +import os +from pathlib import Path + from textual.app import App, ComposeResult from textual.containers import Container from textual.widgets import Footer, Header from .models.workflow import WorkflowState +from .screens.env_setup import EnvSetupModal, MissingEnvSetupPromptModal from .screens.login import LoginView from .screens.wizard import WizardView from .services.base import ProxmoxService from .services.factory import ProxmoxServiceFactory -from .settings import AppSettings +from .settings import ENV_VAR_SPECS, AppSettings, resolve_dotenv_paths, write_config_dotenv from .terminal_compat import build_driver_class @@ -22,11 +26,22 @@ class PveVmSetupApp(App[None]): settings: AppSettings, *, service: ProxmoxService | None = None, + prompt_for_missing_env_setup: bool | None = None, + dotenv_path: str | Path = ".env", + config_dotenv_path: str | Path | None = None, ) -> None: super().__init__(driver_class=build_driver_class()) self.settings = settings self.workflow = WorkflowState() self.service = service or ProxmoxServiceFactory.create(settings) + self._prompt_for_missing_env_setup = ( + service is None + if prompt_for_missing_env_setup is None + else prompt_for_missing_env_setup + ) + self._dotenv_path = dotenv_path + self._config_dotenv_path = config_dotenv_path + self._missing_env_prompted = False def compose(self) -> ComposeResult: yield Header() @@ -34,6 +49,9 @@ class PveVmSetupApp(App[None]): yield LoginView(self.settings, self.workflow, self.service) yield Footer() + def on_mount(self) -> None: + self.call_after_refresh(self._maybe_prompt_for_missing_env_setup) + def on_unmount(self) -> None: close = getattr(self.service, "close", None) if callable(close): @@ -44,3 +62,61 @@ class PveVmSetupApp(App[None]): wizard = WizardView(self.settings, self.workflow, self.service) await self.query_one("#app-body", Container).mount(wizard) wizard.activate() + + def _maybe_prompt_for_missing_env_setup(self) -> None: + if self._missing_env_prompted or not self._prompt_for_missing_env_setup: + return + dotenv_paths = resolve_dotenv_paths( + dotenv_path=self._dotenv_path, + config_dotenv_path=self._config_dotenv_path, + ) + if dotenv_paths.any_exists: + return + self._missing_env_prompted = True + self.push_screen( + MissingEnvSetupPromptModal(dotenv_paths), + self._handle_missing_env_prompt_result, + ) + + def _handle_missing_env_prompt_result(self, should_setup: bool | None) -> None: + if not should_setup: + return + self.push_screen( + EnvSetupModal(self._current_env_values()), + self._handle_env_setup_result, + ) + + def _handle_env_setup_result(self, values: dict[str, str] | None) -> None: + if not values: + return + write_config_dotenv(values, config_dotenv_path=self._config_dotenv_path) + self._apply_runtime_env(values) + self._reload_settings_and_service() + + def _current_env_values(self) -> dict[str, str]: + return { + spec.name: os.environ.get(spec.name, "") + for spec in ENV_VAR_SPECS + if os.environ.get(spec.name, "") + } + + def _apply_runtime_env(self, values: dict[str, str]) -> None: + for spec in ENV_VAR_SPECS: + if spec.name in values: + os.environ[spec.name] = values[spec.name] + else: + os.environ.pop(spec.name, None) + + def _reload_settings_and_service(self) -> None: + previous_service = self.service + self.settings = AppSettings.from_env( + dotenv_path=self._dotenv_path, + config_dotenv_path=self._config_dotenv_path, + ) + self.service = ProxmoxServiceFactory.create(self.settings) + if self.query(LoginView): + self.query_one(LoginView).reconfigure(self.settings, self.service) + if previous_service is not self.service: + close = getattr(previous_service, "close", None) + if callable(close): + close() diff --git a/src/pve_vm_setup/screens/env_setup.py b/src/pve_vm_setup/screens/env_setup.py new file mode 100644 index 0000000..5bf3074 --- /dev/null +++ b/src/pve_vm_setup/screens/env_setup.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from textual import on +from textual.app import ComposeResult +from textual.containers import HorizontalGroup, ScrollableContainer, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Input, Static + +from ..errors import SettingsError +from ..settings import ENV_VAR_SPECS, AppSettings, DotenvPaths + + +def _input_id(name: str) -> str: + return f"env-{name.lower().replace('_', '-')}" + + +class MissingEnvSetupPromptModal(ModalScreen[bool | None]): + DEFAULT_CSS = """ + MissingEnvSetupPromptModal { + align: center middle; + } + + #missing-env-dialog { + width: 68; + max-width: 100%; + height: auto; + border: round $accent; + background: $surface; + padding: 1 2; + } + + #missing-env-title { + text-style: bold; + } + + #missing-env-actions { + margin-top: 1; + height: auto; + width: 1fr; + align-horizontal: right; + } + + #missing-env-actions Button { + margin-left: 1; + min-width: 14; + } + """ + + BINDINGS = [("escape", "cancel", "Skip")] + + def __init__(self, paths: DotenvPaths) -> None: + super().__init__() + self._paths = paths + + def compose(self) -> ComposeResult: + with Vertical(id="missing-env-dialog"): + yield Static("Set up a .env file?", id="missing-env-title") + yield Static( + "No .env file was found in the current directory or the standard config path." + ) + yield Static(f"Current directory: {self._paths.cwd}") + yield Static(f"Config file: {self._paths.config}") + yield Static("PROXMOX_URL is required. All other values may be left blank.") + with HorizontalGroup(id="missing-env-actions"): + yield Button("Skip", id="missing-env-skip") + yield Button("Set Up", id="missing-env-setup", variant="primary") + + def on_mount(self) -> None: + self.set_focus(self.query_one("#missing-env-setup", Button)) + + def action_cancel(self) -> None: + self.dismiss(False) + + @on(Button.Pressed, "#missing-env-skip") + def on_skip_pressed(self) -> None: + self.dismiss(False) + + @on(Button.Pressed, "#missing-env-setup") + def on_setup_pressed(self) -> None: + self.dismiss(True) + + +class EnvSetupModal(ModalScreen[dict[str, str] | None]): + DEFAULT_CSS = """ + EnvSetupModal { + align: center middle; + } + + #env-setup-dialog { + width: 92; + max-width: 100%; + height: 90%; + border: round $accent; + background: $surface; + padding: 1 2; + } + + #env-setup-title { + text-style: bold; + margin-bottom: 1; + } + + #env-setup-form { + height: 1fr; + padding-right: 1; + } + + .env-label { + margin-top: 1; + } + + #env-setup-status { + margin-top: 1; + color: $text-muted; + } + + #env-setup-actions { + margin-top: 1; + height: auto; + width: 1fr; + align-horizontal: right; + } + + #env-setup-actions Button { + margin-left: 1; + min-width: 14; + } + """ + + BINDINGS = [("escape", "cancel", "Cancel")] + + def __init__(self, initial_values: Mapping[str, str] | None = None) -> None: + super().__init__() + self._initial_values = dict(initial_values or {}) + + def compose(self) -> ComposeResult: + with Vertical(id="env-setup-dialog"): + yield Static("Create Proxmox Environment File", id="env-setup-title") + yield Static("Enter values for the supported variables. Leave optional values blank.") + with ScrollableContainer(id="env-setup-form"): + for spec in ENV_VAR_SPECS: + label = spec.name if not spec.required else f"{spec.name} *" + yield Static(label, classes="env-label") + yield Input( + value=self._initial_values.get(spec.name, ""), + placeholder=spec.placeholder, + password=spec.secret, + id=_input_id(spec.name), + ) + yield Static( + "Values will be saved to ~/.config/pve-vm-setup/.env", id="env-setup-status" + ) + with HorizontalGroup(id="env-setup-actions"): + yield Button("Cancel", id="env-setup-cancel") + yield Button("Save", id="env-setup-save", variant="primary") + + def on_mount(self) -> None: + self.set_focus(self.query_one(f"#{_input_id('PROXMOX_URL')}", Input)) + + def action_cancel(self) -> None: + self.dismiss(None) + + @on(Button.Pressed, "#env-setup-cancel") + def on_cancel_pressed(self) -> None: + self.dismiss(None) + + @on(Button.Pressed, "#env-setup-save") + def on_save_pressed(self) -> None: + values = self._collect_values() + if not values.get("PROXMOX_URL"): + self._show_status("PROXMOX_URL is required.") + return + try: + AppSettings.from_env(values, load_dotenv_file=False) + except SettingsError as exc: + self._show_status(str(exc)) + return + self.dismiss(values) + + def _collect_values(self) -> dict[str, str]: + values: dict[str, str] = {} + for spec in ENV_VAR_SPECS: + widget = self.query_one(f"#{_input_id(spec.name)}", Input) + value = widget.value if spec.secret else widget.value.strip() + if value: + values[spec.name] = value + return values + + def _show_status(self, message: str) -> None: + self.query_one("#env-setup-status", Static).update(message) diff --git a/src/pve_vm_setup/screens/login.py b/src/pve_vm_setup/screens/login.py index 65bb9ea..dc69611 100644 --- a/src/pve_vm_setup/screens/login.py +++ b/src/pve_vm_setup/screens/login.py @@ -86,6 +86,18 @@ class LoginView(Vertical): self.call_after_refresh(self.app.set_focus, username_input) self.run_worker(self._load_realms, thread=True, exclusive=True) + def reconfigure(self, settings: AppSettings, service: ProxmoxService) -> None: + self._settings = settings + self._service = service + self.query_one("#mode", Static).update( + f"Mode: {self._service.mode} on {self._settings.sanitized_host}" + ) + self.query_one("#username", Input).value = self._settings.proxmox_user or "" + self.query_one("#password", Input).value = self._settings.proxmox_password or "" + self.query_one("#realm", Select).set_options([]) + self._show_status("Loading realms...") + self.run_worker(self._load_realms, thread=True, exclusive=True) + def _load_realms(self) -> None: try: realms = self._service.load_realms() diff --git a/src/pve_vm_setup/settings.py b/src/pve_vm_setup/settings.py index 9e9a17c..c3fd246 100644 --- a/src/pve_vm_setup/settings.py +++ b/src/pve_vm_setup/settings.py @@ -12,6 +12,43 @@ from dotenv import dotenv_values from .errors import SettingsError +@dataclass(frozen=True) +class EnvVarSpec: + name: str + placeholder: str = "" + required: bool = False + secret: bool = False + + +@dataclass(frozen=True) +class DotenvPaths: + cwd: Path + config: Path + + @property + def any_exists(self) -> bool: + return self.cwd.exists() or self.config.exists() + + +ENV_VAR_SPECS: tuple[EnvVarSpec, ...] = ( + EnvVarSpec("PROXMOX_URL", placeholder="https://pve.example.com:8006", required=True), + EnvVarSpec("PROXMOX_REALM", placeholder="pam"), + EnvVarSpec("PROXMOX_USER", placeholder="root"), + EnvVarSpec("PROXMOX_PASSWORD", placeholder="Optional", secret=True), + EnvVarSpec("PROXMOX_VERIFY_TLS", placeholder="true or false"), + EnvVarSpec("PROXMOX_API_BASE", placeholder="/api2/json"), + EnvVarSpec("PROXMOX_REQUEST_TIMEOUT_SECONDS", placeholder="15"), + EnvVarSpec("PROXMOX_DEFAULT_ISO_SELECTOR", placeholder="*nixos*"), + EnvVarSpec("PROXMOX_PREVENT_CREATE", placeholder="true or false"), + EnvVarSpec("PROXMOX_ENABLE_TEST_MODE", placeholder="true or false"), + EnvVarSpec("PROXMOX_TEST_NODE", placeholder="pve-test-01"), + EnvVarSpec("PROXMOX_TEST_POOL", placeholder="sandbox"), + EnvVarSpec("PROXMOX_TEST_TAG", placeholder="codex-e2e"), + EnvVarSpec("PROXMOX_TEST_VM_NAME_PREFIX", placeholder="codex-e2e-"), + EnvVarSpec("PROXMOX_KEEP_FAILED_VM", placeholder="true or false"), +) + + def _load_dotenv(path: Path) -> dict[str, str]: return { key: value @@ -20,6 +57,45 @@ def _load_dotenv(path: Path) -> dict[str, str]: } +def resolve_dotenv_paths( + *, + dotenv_path: str | Path = ".env", + config_dotenv_path: str | Path | None = None, +) -> DotenvPaths: + return DotenvPaths( + cwd=Path(dotenv_path), + config=( + Path(config_dotenv_path).expanduser() + if config_dotenv_path is not None + else Path.home() / ".config" / "pve-vm-setup" / ".env" + ), + ) + + +def _format_dotenv_value(value: str) -> str: + if re.fullmatch(r"[A-Za-z0-9_./:@+-]+", value): + return value + escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + return f'"{escaped}"' + + +def write_config_dotenv( + values: Mapping[str, str | None], + *, + config_dotenv_path: str | Path | None = None, +) -> Path: + path = resolve_dotenv_paths(config_dotenv_path=config_dotenv_path).config + lines = ["# Generated by pve-vm-setup"] + for spec in ENV_VAR_SPECS: + raw_value = values.get(spec.name) + if raw_value is None or raw_value == "": + continue + lines.append(f"{spec.name}={_format_dotenv_value(raw_value)}") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + def _parse_bool(value: str | None, *, default: bool) -> bool: if value is None or value == "": return default @@ -105,14 +181,12 @@ class AppSettings: ) -> AppSettings: raw: dict[str, str] = {} if load_dotenv_file: - 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" + dotenv_paths = resolve_dotenv_paths( + dotenv_path=dotenv_path, + config_dotenv_path=config_dotenv_path, ) - raw.update(_load_dotenv(cwd_dotenv)) - raw.update(_load_dotenv(home_config_dotenv)) + raw.update(_load_dotenv(dotenv_paths.cwd)) + raw.update(_load_dotenv(dotenv_paths.config)) raw.update(os.environ if env is None else env) api_base = raw.get("PROXMOX_API_BASE", "/api2/json").strip() or "/api2/json" diff --git a/tests/test_app.py b/tests/test_app.py index dd7411b..b329b73 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import time from collections import Counter +from pathlib import Path import pytest from textual.app import App, ComposeResult @@ -11,8 +12,10 @@ from textual.widgets import Button, Checkbox, Input, Select, Static from pve_vm_setup.app import PveVmSetupApp from pve_vm_setup.models.workflow import WorkflowState +from pve_vm_setup.screens.env_setup import EnvSetupModal, MissingEnvSetupPromptModal from pve_vm_setup.screens.login import LoginView from pve_vm_setup.screens.wizard import NO_DISK_SELECTED, AutoStartConfirmModal, WizardView +from pve_vm_setup.services.factory import ProxmoxServiceFactory from pve_vm_setup.services.fake import FakeProxmoxService from pve_vm_setup.settings import AppSettings @@ -85,6 +88,80 @@ async def test_main_app_mounts_wizard_only_after_login() -> None: assert app.focused is app.query_one("#general-name", Input) +@pytest.mark.asyncio +async def test_main_app_prompts_for_env_setup_when_no_dotenv_exists( + tmp_path: Path, monkeypatch +) -> None: + home = tmp_path / "home" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(home)) + + app = PveVmSetupApp( + AppSettings.from_env({}, load_dotenv_file=True), + prompt_for_missing_env_setup=True, + ) + + async with app.run_test() as pilot: + await pilot.pause() + + assert isinstance(app.screen_stack[-1], MissingEnvSetupPromptModal) + + +@pytest.mark.asyncio +async def test_main_app_can_save_env_setup_and_refresh_login_mode( + tmp_path: Path, monkeypatch +) -> None: + class LiveLikeService(FakeProxmoxService): + mode = "live" + + home = tmp_path / "home" + config_path = home / ".config" / "pve-vm-setup" / ".env" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("PROXMOX_URL", raising=False) + monkeypatch.delenv("PROXMOX_USER", raising=False) + + def create_service(settings: AppSettings): + if settings.proxmox_url: + return LiveLikeService() + return FakeProxmoxService() + + monkeypatch.setattr(ProxmoxServiceFactory, "create", staticmethod(create_service)) + + app = PveVmSetupApp( + AppSettings.from_env({}, load_dotenv_file=True), + prompt_for_missing_env_setup=True, + ) + + async with app.run_test() as pilot: + await pilot.pause() + assert isinstance(app.screen_stack[-1], MissingEnvSetupPromptModal) + + app.query_one("#missing-env-setup", Button).press() + await pilot.pause() + assert isinstance(app.screen_stack[-1], EnvSetupModal) + + app.query_one("#env-proxmox-url", Input).value = "https://pve.example.invalid:8006" + app.query_one("#env-proxmox-user", Input).value = "root" + app.query_one("#env-setup-save", Button).press() + + for _ in range(10): + await pilot.pause(0.05) + if not app.screen_stack or not isinstance(app.screen_stack[-1], EnvSetupModal): + break + + assert config_path.exists() + content = config_path.read_text(encoding="utf-8") + assert "PROXMOX_URL=https://pve.example.invalid:8006" in content + assert "PROXMOX_USER=root" in content + assert "PROXMOX_PASSWORD" not in content + + assert "Mode: live on pve.example.invalid:8006" == str( + app.query_one("#mode", Static).renderable + ) + assert app.query_one("#username", Input).value == "root" + + @pytest.mark.asyncio async def test_wizard_activation_focuses_first_editable_field() -> None: service = FakeProxmoxService() diff --git a/tests/test_settings.py b/tests/test_settings.py index 4dc58fc..9fd1516 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest from pve_vm_setup.errors import SettingsError -from pve_vm_setup.settings import AppSettings +from pve_vm_setup.settings import AppSettings, resolve_dotenv_paths, write_config_dotenv def test_settings_load_defaults_and_normalize_api_base() -> None: @@ -152,3 +152,37 @@ def test_settings_prefers_environment_over_config_and_current_directory_dotenv( ) assert settings.proxmox_url == "https://env.example.invalid:8006" + + +def test_resolve_dotenv_paths_uses_standard_config_location(tmp_path: Path, monkeypatch) -> None: + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + + paths = resolve_dotenv_paths() + + assert paths.cwd == Path(".env") + assert paths.config == home / ".config" / "pve-vm-setup" / ".env" + assert paths.any_exists is False + + +def test_write_config_dotenv_creates_parent_directory_and_skips_blank_values( + tmp_path: Path, +) -> None: + target = tmp_path / "home" / ".config" / "pve-vm-setup" / ".env" + + written_path = write_config_dotenv( + { + "PROXMOX_URL": "https://pve.example.invalid:8006", + "PROXMOX_USER": "root", + "PROXMOX_PASSWORD": "", + }, + config_dotenv_path=target, + ) + + assert written_path == target + assert target.exists() + assert target.read_text(encoding="utf-8") == ( + "# Generated by pve-vm-setup\n" + "PROXMOX_URL=https://pve.example.invalid:8006\n" + "PROXMOX_USER=root\n" + )