Add guided env setup modal
This commit is contained in:
parent
c9859a5324
commit
8c90306c75
6 changed files with 474 additions and 9 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
192
src/pve_vm_setup/screens/env_setup.py
Normal file
192
src/pve_vm_setup/screens/env_setup.py
Normal 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)
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue