Add guided env setup modal

This commit is contained in:
Philip Henning 2026-03-09 11:25:50 +01:00
parent c9859a5324
commit 8c90306c75
6 changed files with 474 additions and 9 deletions

View file

@ -1,15 +1,19 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Container from textual.containers import Container
from textual.widgets import Footer, Header from textual.widgets import Footer, Header
from .models.workflow import WorkflowState from .models.workflow import WorkflowState
from .screens.env_setup import EnvSetupModal, MissingEnvSetupPromptModal
from .screens.login import LoginView from .screens.login import LoginView
from .screens.wizard import WizardView from .screens.wizard import WizardView
from .services.base import ProxmoxService from .services.base import ProxmoxService
from .services.factory import ProxmoxServiceFactory 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 from .terminal_compat import build_driver_class
@ -22,11 +26,22 @@ class PveVmSetupApp(App[None]):
settings: AppSettings, settings: AppSettings,
*, *,
service: ProxmoxService | None = None, 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: ) -> None:
super().__init__(driver_class=build_driver_class()) super().__init__(driver_class=build_driver_class())
self.settings = settings self.settings = settings
self.workflow = WorkflowState() self.workflow = WorkflowState()
self.service = service or ProxmoxServiceFactory.create(settings) 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: def compose(self) -> ComposeResult:
yield Header() yield Header()
@ -34,6 +49,9 @@ class PveVmSetupApp(App[None]):
yield LoginView(self.settings, self.workflow, self.service) yield LoginView(self.settings, self.workflow, self.service)
yield Footer() yield Footer()
def on_mount(self) -> None:
self.call_after_refresh(self._maybe_prompt_for_missing_env_setup)
def on_unmount(self) -> None: def on_unmount(self) -> None:
close = getattr(self.service, "close", None) close = getattr(self.service, "close", None)
if callable(close): if callable(close):
@ -44,3 +62,61 @@ class PveVmSetupApp(App[None]):
wizard = WizardView(self.settings, self.workflow, self.service) wizard = WizardView(self.settings, self.workflow, self.service)
await self.query_one("#app-body", Container).mount(wizard) await self.query_one("#app-body", Container).mount(wizard)
wizard.activate() 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()

View file

@ -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)

View file

@ -86,6 +86,18 @@ class LoginView(Vertical):
self.call_after_refresh(self.app.set_focus, username_input) self.call_after_refresh(self.app.set_focus, username_input)
self.run_worker(self._load_realms, thread=True, exclusive=True) 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: def _load_realms(self) -> None:
try: try:
realms = self._service.load_realms() realms = self._service.load_realms()

View file

@ -12,6 +12,43 @@ from dotenv import dotenv_values
from .errors import SettingsError 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]: def _load_dotenv(path: Path) -> dict[str, str]:
return { return {
key: value 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: 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
@ -105,14 +181,12 @@ class AppSettings:
) -> AppSettings: ) -> AppSettings:
raw: dict[str, str] = {} raw: dict[str, str] = {}
if load_dotenv_file: if load_dotenv_file:
cwd_dotenv = Path(dotenv_path) dotenv_paths = resolve_dotenv_paths(
home_config_dotenv = ( dotenv_path=dotenv_path,
Path(config_dotenv_path).expanduser() config_dotenv_path=config_dotenv_path,
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(dotenv_paths.cwd))
raw.update(_load_dotenv(home_config_dotenv)) raw.update(_load_dotenv(dotenv_paths.config))
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"

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import time import time
from collections import Counter from collections import Counter
from pathlib import Path
import pytest import pytest
from textual.app import App, ComposeResult 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.app import PveVmSetupApp
from pve_vm_setup.models.workflow import WorkflowState 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.login import LoginView
from pve_vm_setup.screens.wizard import NO_DISK_SELECTED, AutoStartConfirmModal, WizardView 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.services.fake import FakeProxmoxService
from pve_vm_setup.settings import AppSettings 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) 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 @pytest.mark.asyncio
async def test_wizard_activation_focuses_first_editable_field() -> None: async def test_wizard_activation_focuses_first_editable_field() -> None:
service = FakeProxmoxService() service = FakeProxmoxService()

View file

@ -3,7 +3,7 @@ from pathlib import Path
import pytest import pytest
from pve_vm_setup.errors import SettingsError 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: 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" 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"
)