Initial working version

This commit is contained in:
Philip Henning 2026-03-08 18:32:25 +01:00
parent 34a0627e76
commit b6886cb34a
61 changed files with 4475 additions and 6 deletions

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
PROXMOX_URL=https://proxmox.example.invalid:8006
PROXMOX_REALM=pam
PROXMOX_USER=root
PROXMOX_PASSWORD=replace-me
PROXMOX_VERIFY_TLS=false
PROXMOX_API_BASE=/api2/json
PROXMOX_PREVENT_CREATE=false
PROXMOX_ENABLE_TEST_MODE=false
PROXMOX_TEST_NODE=
PROXMOX_TEST_POOL=
PROXMOX_TEST_TAG=codex-e2e
PROXMOX_TEST_VM_NAME_PREFIX=codex-e2e-
PROXMOX_KEEP_FAILED_VM=true
PROXMOX_REQUEST_TIMEOUT_SECONDS=15

View file

@ -108,7 +108,7 @@ Expected responsibilities:
- provide shared app context to screens
- coordinate back/next/confirm navigation
The current run command placeholder in `README.md` and `TASKS.md` is `uv run python -m your_app`, so the real package/module name is still unresolved.
The current run command in `README.md` and `TASKS.md` is `uv run python -m pve_vm_setup`.
### 2. Screens
@ -357,7 +357,7 @@ The create-then-configure request sequence is especially important to cover in s
`TASKS.md` does not prescribe exact paths, but it does require a separation of concerns. A structure consistent with the current requirements would be:
```text
your_app/
pve_vm_setup/
__main__.py
app.py
screens/
@ -392,7 +392,6 @@ These defaults are central enough to architecture because they belong in domain/
The available resources leave several architectural details unresolved:
- What concrete Python package/module name should replace `your_app`?
- Which Proxmox authentication mechanism should be used under the hood: ticket/cookie, API token, or both?
- How should session persistence work across screens and retries?
- Does the app target a single Proxmox node/cluster endpoint or support multiple saved endpoints?

View file

@ -1,11 +1,26 @@
## Commands
- Install: `uv sync`
- Run app: `uv run python -m your_app`
- Run app: `uv run python -m pve_vm_setup`
- Run live diagnostics: `uv run python -m pve_vm_setup --doctor-live`
- Run tests: `uv run pytest`
- Run read-only live tests: `uv run pytest -m live`
- Run create-gated live tests: `uv run pytest -m live_create`
- Lint: `uv run ruff check .`
- Format: `uv run ruff format .`
## Live configuration
Start from `.env.example` and provide the Proxmox credentials in `.env`.
Additional live-access controls:
- `PROXMOX_VERIFY_TLS=false` disables certificate verification for internal/self-signed installs
- `PROXMOX_API_BASE=/api2/json` makes the API base explicit
- `PROXMOX_PREVENT_CREATE=false` allows VM creation by default; set it to `true` to block creates
- `PROXMOX_ENABLE_TEST_MODE=true` enables scoped test mode for live creates
- When test mode is enabled, `PROXMOX_TEST_NODE`, `PROXMOX_TEST_POOL`, `PROXMOX_TEST_TAG`, and `PROXMOX_TEST_VM_NAME_PREFIX` are required and are used to constrain and mark created VMs
## Engineering rules
- Write tests before implementation

View file

@ -22,7 +22,7 @@ Use these rules for every implementation task in this repository:
Codex should use these commands:
- Install dependencies: `uv sync`
- Run app: `uv run python -m your_app`
- Run app: `uv run python -m pve_vm_setup`
- Run tests: `uv run pytest`
- Run lint checks: `uv run ruff check .`
- Format code: `uv run ruff format .`
@ -41,7 +41,7 @@ Create the initial Textual application structure and make the repository runnabl
Requirements:
- Create the application entrypoint used by `uv run python -m your_app`.
- Create the application entrypoint used by `uv run python -m pve_vm_setup`.
- Set up a project structure that separates app shell, screens, widgets, models, and services.
- Add the initial test setup for unit tests, Textual interaction tests, and snapshot tests.
- Add a central state or domain module for the VM configuration workflow.

40
pyproject.toml Normal file
View file

@ -0,0 +1,40 @@
[project]
name = "pve-vm-setup"
version = "0.1.0"
description = "Textual TUI for creating Proxmox VMs with live diagnostics."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27,<0.29",
"python-dotenv>=1.0,<2.0",
"textual>=0.63,<0.90",
]
[dependency-groups]
dev = [
"pytest>=8.3,<9.0",
"pytest-asyncio>=0.24,<1.0",
"ruff>=0.9,<1.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/pve_vm_setup"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
markers = [
"live: hit a real Proxmox API endpoint",
"live_create: create real Proxmox resources and clean them up",
]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]

View file

@ -0,0 +1,3 @@
"""Proxmox VM setup TUI."""
__all__ = []

View file

@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

46
src/pve_vm_setup/app.py Normal file
View file

@ -0,0 +1,46 @@
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.widgets import Footer, Header
from .models.workflow import WorkflowState
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 .terminal_compat import build_driver_class
class PveVmSetupApp(App[None]):
TITLE = "Proxmox VM Setup"
SUB_TITLE = "Live-access foundation"
def __init__(
self,
settings: AppSettings,
*,
service: ProxmoxService | None = None,
) -> None:
super().__init__(driver_class=build_driver_class())
self.settings = settings
self.workflow = WorkflowState()
self.service = service or ProxmoxServiceFactory.create(settings)
def compose(self) -> ComposeResult:
yield Header()
with Container(id="app-body"):
yield LoginView(self.settings, self.workflow, self.service)
yield Footer()
def on_unmount(self) -> None:
close = getattr(self.service, "close", None)
if callable(close):
close()
async def on_login_view_authenticated(self, _: LoginView.Authenticated) -> None:
self.query_one(LoginView).remove()
wizard = WizardView(self.settings, self.workflow, self.service)
await self.query_one("#app-body", Container).mount(wizard)
wizard.activate()

33
src/pve_vm_setup/cli.py Normal file
View file

@ -0,0 +1,33 @@
from __future__ import annotations
import argparse
import sys
from .app import PveVmSetupApp
from .doctor import run_live_doctor
from .settings import AppSettings
from .terminal_compat import apply_runtime_compatibility
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Proxmox VM setup TUI")
parser.add_argument(
"--doctor-live",
action="store_true",
help="Run live Proxmox connectivity and authentication diagnostics.",
)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
apply_runtime_compatibility()
settings = AppSettings.from_env()
if args.doctor_live:
return run_live_doctor(settings, stream=sys.stdout)
app = PveVmSetupApp(settings)
app.run(mouse=False)
return 0

View file

@ -0,0 +1,80 @@
from __future__ import annotations
from typing import TextIO
from .errors import ProxmoxError, SettingsError
from .services.factory import ProxmoxServiceFactory
from .settings import AppSettings
def run_live_doctor(
settings: AppSettings,
*,
stream: TextIO,
service_factory: type[ProxmoxServiceFactory] = ProxmoxServiceFactory,
) -> int:
try:
settings.validate_live_requirements()
settings.safety_policy.validate()
except SettingsError as exc:
stream.write(f"FAIL configuration: {exc}\n")
return 1
stream.write("Target\n")
stream.write(f" host: {settings.sanitized_host}\n")
stream.write(f" api_base: {settings.proxmox_api_base}\n")
stream.write(f" realm: {settings.proxmox_realm}\n")
stream.write(f" verify_tls: {settings.proxmox_verify_tls}\n")
stream.write(f" prevent_create: {settings.safety_policy.prevent_create}\n")
stream.write(f" enable_test_mode: {settings.safety_policy.enable_test_mode}\n")
service = service_factory.create(settings)
try:
stream.write("1. Checking HTTPS reachability...\n")
transport_status = service.check_connectivity()
stream.write(f" OK {transport_status}\n")
stream.write("2. Checking API base path...\n")
release = service.check_api_base()
stream.write(f" OK release={release}\n")
stream.write("3. Loading realms...\n")
realms = service.load_realms()
stream.write(f" OK realms={','.join(realm.name for realm in realms)}\n")
stream.write("4. Attempting login...\n")
session = service.login(
settings.proxmox_user or "",
settings.proxmox_password or "",
settings.proxmox_realm or "",
)
stream.write(f" OK authenticated_as={session.username}\n")
if settings.safety_policy.enable_test_mode:
stream.write("5. Validating test mode create scope...\n")
nodes = {node.name for node in service.load_nodes()}
if settings.safety_policy.test_node not in nodes:
raise SettingsError(
f"Configured test node {settings.safety_policy.test_node!r} was not found."
)
stream.write(f" OK node={settings.safety_policy.test_node}\n")
if settings.safety_policy.test_pool:
pools = {pool.poolid for pool in service.load_pools()}
if settings.safety_policy.test_pool not in pools:
raise SettingsError(
f"Configured test pool {settings.safety_policy.test_pool!r} was not found."
)
stream.write(f" OK pool={settings.safety_policy.test_pool}\n")
stream.write(f" tag={settings.safety_policy.test_tag}\n")
stream.write(f" name_prefix={settings.safety_policy.test_vm_name_prefix}\n")
except (ProxmoxError, SettingsError, ValueError) as exc:
stream.write(f"FAIL {exc}\n")
return 1
finally:
close = getattr(service, "close", None)
if callable(close):
close()
stream.write("Doctor finished successfully.\n")
return 0

320
src/pve_vm_setup/domain.py Normal file
View file

@ -0,0 +1,320 @@
from __future__ import annotations
import re
from dataclasses import replace
from .errors import SettingsError
from .models.workflow import DiskConfig, ReferenceData, VmConfig
from .settings import AppSettings
_NIXOS_ISO_PATTERN = re.compile(
r"nixos-minimal-(?P<year>\d{2})[.-](?P<month>\d{2})\.[A-Za-z0-9]+-[A-Za-z0-9_]+-linux\.iso$"
)
def select_latest_nixos_iso(isos: list[str]) -> str | None:
candidates: list[tuple[int, int, str]] = []
for iso in isos:
match = _NIXOS_ISO_PATTERN.search(iso)
if match:
candidates.append((int(match.group("year")), int(match.group("month")), iso))
if not candidates:
return None
return max(candidates)[2]
def build_startup_value(order: str, up: str, down: str) -> str:
parts: list[str] = []
if order.strip():
parts.append(f"order={order.strip()}")
if up.strip():
parts.append(f"up={up.strip()}")
if down.strip():
parts.append(f"down={down.strip()}")
return ",".join(parts)
def effective_vm_config(config: VmConfig, settings: AppSettings) -> VmConfig:
result = replace(config)
result.general = replace(config.general)
result.general.name = settings.safety_policy.effective_vm_name(config.general.name.strip())
if not settings.safety_policy.enable_test_mode:
result.general.tags = [tag for tag in config.general.tags if tag]
return result
tags = [tag for tag in config.general.tags if tag]
if settings.safety_policy.test_tag not in tags:
tags.append(settings.safety_policy.test_tag)
result.general.tags = sorted(dict.fromkeys(tags))
return result
def validate_step(
step: str,
config: VmConfig,
settings: AppSettings,
references: ReferenceData,
) -> list[str]:
errors: list[str] = []
if step == "general":
if not config.general.node:
errors.append("Node is required.")
if config.general.vmid < 100:
errors.append("VM ID must be at least 100.")
if not config.general.name.strip():
errors.append("Name is required.")
for label, value in [
("Startup order", config.general.startup_order),
("Startup delay", config.general.startup_delay),
("Shutdown timeout", config.general.shutdown_timeout),
]:
if value.strip() and not value.strip().isdigit():
errors.append(f"{label} must be an integer.")
if settings.safety_policy.enable_test_mode and settings.safety_policy.test_node:
if config.general.node != settings.safety_policy.test_node:
errors.append(
f"Live create mode is restricted to node {settings.safety_policy.test_node}."
)
if settings.safety_policy.enable_test_mode and settings.safety_policy.test_pool:
if config.general.pool != settings.safety_policy.test_pool:
errors.append(
f"Live create mode is restricted to pool {settings.safety_policy.test_pool}."
)
if step == "os":
if config.os.media_choice == "iso":
if not config.os.storage:
errors.append("ISO storage is required.")
if not config.os.iso:
errors.append("ISO selection is required.")
if config.os.media_choice == "physical" and not config.os.physical_drive_path.strip():
errors.append("Physical disc drive path is required.")
if step == "system":
if config.system.add_efi_disk and not config.system.efi_storage:
errors.append("EFI storage is required when EFI disk is enabled.")
if step == "disks":
slots: set[str] = set()
for disk in config.disks:
if disk.slot_name in slots:
errors.append(f"Duplicate disk slot {disk.slot_name}.")
slots.add(disk.slot_name)
if disk.size_gib <= 0:
errors.append(f"Disk {disk.slot_name} size must be greater than zero.")
if not disk.storage:
errors.append(f"Disk {disk.slot_name} storage is required.")
if step == "cpu":
if config.cpu.cores <= 0:
errors.append("CPU cores must be greater than zero.")
if config.cpu.sockets <= 0:
errors.append("CPU sockets must be greater than zero.")
if step == "memory":
if config.memory.memory_mib <= 0:
errors.append("Memory must be greater than zero.")
if config.memory.ballooning:
if config.memory.min_memory_mib <= 0:
errors.append("Min memory must be greater than zero when ballooning is enabled.")
if config.memory.min_memory_mib > config.memory.memory_mib:
errors.append("Min memory cannot exceed memory size.")
if step == "network" and not config.network.no_network_device:
if not config.network.bridge:
errors.append("Bridge is required unless networking is disabled.")
for label, value in [
("VLAN tag", config.network.vlan_tag),
("MTU", config.network.mtu),
("Multiqueue", config.network.multiqueue),
]:
if value.strip() and not value.strip().isdigit():
errors.append(f"{label} must be an integer.")
if step == "confirm" and not settings.safety_policy.allow_create:
errors.append("Set PROXMOX_PREVENT_CREATE=false to enable VM creation.")
return errors
def validate_all_steps(
config: VmConfig, settings: AppSettings, references: ReferenceData
) -> list[str]:
all_errors: list[str] = []
for step in ["general", "os", "system", "disks", "cpu", "memory", "network", "confirm"]:
all_errors.extend(validate_step(step, config, settings, references))
return all_errors
def _bool_int(value: bool) -> int:
return 1 if value else 0
def build_disk_value(disk: DiskConfig) -> str:
options = [
f"{disk.storage}:{disk.size_gib}",
f"format={disk.format}",
f"cache={disk.cache}",
f"discard={'on' if disk.discard else 'ignore'}",
f"iothread={_bool_int(disk.io_thread)}",
f"ssd={_bool_int(disk.ssd_emulation)}",
f"backup={_bool_int(disk.backup)}",
f"replicate={_bool_int(not disk.skip_replication)}",
f"aio={disk.async_io}",
]
return ",".join(options)
def build_network_value(config: VmConfig) -> str | None:
if config.network.no_network_device:
return None
parts: list[str] = []
if config.network.mac_address.strip():
parts.append(f"{config.network.model}={config.network.mac_address.strip()}")
else:
parts.append(f"model={config.network.model}")
parts.append(f"bridge={config.network.bridge}")
parts.append(f"firewall={_bool_int(config.network.firewall)}")
parts.append(f"link_down={_bool_int(config.network.disconnected)}")
if config.network.vlan_tag.strip():
parts.append(f"tag={int(config.network.vlan_tag)}")
if config.network.mtu.strip():
parts.append(f"mtu={int(config.network.mtu)}")
if config.network.rate_limit.strip():
parts.append(f"rate={config.network.rate_limit.strip()}")
if config.network.multiqueue.strip():
parts.append(f"queues={int(config.network.multiqueue)}")
return ",".join(parts)
def build_media_value(config: VmConfig) -> str | None:
if config.os.media_choice == "none":
return None
if config.os.media_choice == "iso" and config.os.iso:
return f"{config.os.iso},media=cdrom"
if config.os.media_choice == "physical":
return f"{config.os.physical_drive_path.strip()},media=cdrom"
return None
def build_create_payload(config: VmConfig, settings: AppSettings) -> dict[str, str | int]:
if not settings.safety_policy.allow_create:
raise SettingsError("PROXMOX_PREVENT_CREATE=false is required before creating VMs.")
effective = effective_vm_config(config, settings)
payload: dict[str, str | int] = {
"vmid": effective.general.vmid,
"name": effective.general.name,
"ostype": effective.os.guest_version,
"bios": effective.system.bios,
"machine": effective.system.machine,
"scsihw": effective.system.scsi_controller,
"agent": _bool_int(effective.system.qemu_agent),
"cores": effective.cpu.cores,
"sockets": effective.cpu.sockets,
"cpu": effective.cpu.cpu_type,
"memory": effective.memory.memory_mib,
"balloon": effective.memory.min_memory_mib if effective.memory.ballooning else 0,
"allow-ksm": _bool_int(effective.memory.allow_ksm),
"onboot": _bool_int(effective.general.onboot),
"tags": ";".join(effective.general.tags),
}
if effective.general.pool:
payload["pool"] = effective.general.pool
startup = build_startup_value(
effective.general.startup_order,
effective.general.startup_delay,
effective.general.shutdown_timeout,
)
if startup:
payload["startup"] = startup
if effective.system.graphic_card != "default":
payload["vga"] = effective.system.graphic_card
if effective.system.add_efi_disk:
payload["efidisk0"] = (
f"{effective.system.efi_storage}:1,efitype=4m,"
f"pre-enrolled-keys={_bool_int(effective.system.pre_enrolled_keys)}"
)
if effective.system.tpm_enabled:
payload["tpmstate0"] = f"{effective.system.efi_storage}:4,version=v2.0"
media = build_media_value(effective)
if media:
payload["ide2"] = media
network = build_network_value(effective)
if network:
payload["net0"] = network
for disk in effective.disks:
payload[disk.slot_name] = build_disk_value(disk)
return payload
def build_confirmation_text(config: VmConfig, settings: AppSettings) -> str:
effective = (
effective_vm_config(config, settings) if settings.safety_policy.allow_create else config
)
startup = build_startup_value(
effective.general.startup_order,
effective.general.startup_delay,
effective.general.shutdown_timeout,
)
system_line = (
f"System: machine={effective.system.machine}, "
f"bios={effective.system.bios}, scsi={effective.system.scsi_controller}"
)
efi_line = (
"EFI disk: "
f"{'enabled' if effective.system.add_efi_disk else 'disabled'} "
f"({effective.system.efi_storage or '-'})"
)
cpu_line = (
f"CPU: {effective.cpu.sockets} socket(s), "
f"{effective.cpu.cores} core(s), type={effective.cpu.cpu_type}"
)
memory_line = (
f"Memory: {effective.memory.memory_mib}MiB / balloon minimum "
f"{effective.memory.min_memory_mib if effective.memory.ballooning else 0}MiB"
)
lines = [
f"Node: {effective.general.node}",
f"VM ID: {effective.general.vmid}",
f"Name: {effective.general.name}",
f"Pool: {effective.general.pool or '-'}",
f"Tags: {', '.join(effective.general.tags) or '-'}",
f"HA: {'enabled' if effective.general.ha_enabled else 'disabled'}",
f"On boot: {'enabled' if effective.general.onboot else 'disabled'}",
f"Startup: {startup or '-'}",
"",
f"Media: {effective.os.media_choice}",
f"ISO storage: {effective.os.storage or '-'}",
f"ISO: {effective.os.iso or '-'}",
f"Guest: {effective.os.guest_type} / {effective.os.guest_version}",
"",
system_line,
efi_line,
f"TPM: {'enabled' if effective.system.tpm_enabled else 'disabled'}",
f"Qemu agent: {'enabled' if effective.system.qemu_agent else 'disabled'}",
"",
"Disks:",
]
for disk in effective.disks:
lines.append(
f" - {disk.slot_name}: {disk.storage} {disk.size_gib}GiB "
f"cache={disk.cache} discard={'on' if disk.discard else 'ignore'}"
)
lines.extend(
[
"",
cpu_line,
memory_line,
"",
(
"Network: disabled"
if effective.network.no_network_device
else f"Network: {effective.network.model} on {effective.network.bridge}"
),
]
)
return "\n".join(lines)

View file

@ -0,0 +1,48 @@
class AppError(Exception):
"""Base application error."""
class SettingsError(AppError):
"""Configuration is missing or invalid."""
class ProxmoxError(AppError):
"""Base error raised while talking to Proxmox."""
class ProxmoxTransportError(ProxmoxError):
"""Transport-level failure while talking to Proxmox."""
class ProxmoxConnectError(ProxmoxTransportError):
"""DNS or TCP connection failure."""
class ProxmoxTlsError(ProxmoxTransportError):
"""TLS handshake or certificate verification failure."""
class ProxmoxAuthError(ProxmoxError):
"""Authentication failure."""
class ProxmoxApiError(ProxmoxError):
"""Unexpected HTTP response from the API."""
def __init__(self, message: str, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
class ProxmoxUnexpectedResponseError(ProxmoxError):
"""The API returned an unexpected payload shape."""
class ProxmoxPostCreateError(ProxmoxError):
"""A follow-up step failed after the VM already existed."""
def __init__(self, node: str, vmid: int, step: str, message: str) -> None:
super().__init__(message)
self.node = node
self.vmid = vmid
self.step = step

View file

@ -0,0 +1 @@
"""Application models."""

View file

@ -0,0 +1,157 @@
from __future__ import annotations
from dataclasses import dataclass, field
WIZARD_STEPS = [
"general",
"os",
"system",
"disks",
"cpu",
"memory",
"network",
"confirm",
]
@dataclass
class AuthenticationState:
username: str | None = None
realm: str | None = None
authenticated: bool = False
@dataclass
class GeneralConfig:
node: str = ""
vmid: int = 101
name: str = ""
pool: str = ""
tags: list[str] = field(default_factory=list)
ha_enabled: bool = True
onboot: bool = False
startup_order: str = ""
startup_delay: str = ""
shutdown_timeout: str = ""
@dataclass
class OsConfig:
media_choice: str = "iso"
storage: str = ""
iso: str = ""
physical_drive_path: str = "/dev/sr0"
guest_type: str = "linux"
guest_version: str = "l26"
@dataclass
class SystemConfig:
graphic_card: str = "default"
machine: str = "q35"
bios: str = "ovmf"
add_efi_disk: bool = True
efi_storage: str = "ceph-pool"
pre_enrolled_keys: bool = False
scsi_controller: str = "virtio-scsi-single"
qemu_agent: bool = True
tpm_enabled: bool = False
@dataclass
class DiskConfig:
bus: str = "scsi"
device: int = 0
storage: str = "ceph-pool"
size_gib: int = 32
format: str = "raw"
cache: str = "none"
discard: bool = False
io_thread: bool = True
ssd_emulation: bool = True
backup: bool = True
skip_replication: bool = False
async_io: str = "io_uring"
@property
def slot_name(self) -> str:
return f"{self.bus}{self.device}"
@dataclass
class CpuConfig:
cores: int = 2
sockets: int = 1
cpu_type: str = "host"
@dataclass
class MemoryConfig:
memory_mib: int = 2048
min_memory_mib: int = 2048
ballooning: bool = True
allow_ksm: bool = True
@dataclass
class NetworkConfig:
no_network_device: bool = False
bridge: str = "vmbr9"
vlan_tag: str = ""
model: str = "virtio"
mac_address: str = ""
firewall: bool = True
disconnected: bool = False
mtu: str = ""
rate_limit: str = ""
multiqueue: str = ""
@dataclass
class VmConfig:
general: GeneralConfig = field(default_factory=GeneralConfig)
os: OsConfig = field(default_factory=OsConfig)
system: SystemConfig = field(default_factory=SystemConfig)
disks: list[DiskConfig] = field(default_factory=lambda: [DiskConfig()])
cpu: CpuConfig = field(default_factory=CpuConfig)
memory: MemoryConfig = field(default_factory=MemoryConfig)
network: NetworkConfig = field(default_factory=NetworkConfig)
@dataclass
class ReferenceData:
nodes: list[str] = field(default_factory=list)
pools: list[str] = field(default_factory=list)
existing_tags: list[str] = field(default_factory=list)
bridges: list[str] = field(default_factory=list)
iso_storages: list[str] = field(default_factory=list)
disk_storages: list[str] = field(default_factory=list)
all_storages: list[str] = field(default_factory=list)
isos: list[str] = field(default_factory=list)
@dataclass
class SubmissionState:
phase: str = "idle"
message: str = ""
node: str | None = None
vmid: int | None = None
partial_success: bool = False
@dataclass
class WorkflowState:
current_step_index: int = 0
available_realms: list[str] = field(default_factory=list)
authentication: AuthenticationState = field(default_factory=AuthenticationState)
config: VmConfig = field(default_factory=VmConfig)
reference_data: ReferenceData = field(default_factory=ReferenceData)
submission: SubmissionState = field(default_factory=SubmissionState)
@property
def current_step(self) -> str:
return WIZARD_STEPS[self.current_step_index]
@property
def step_title(self) -> str:
return self.current_step.replace("_", " ").title()

View file

@ -0,0 +1 @@
"""Textual screens."""

View file

@ -0,0 +1,159 @@
from __future__ import annotations
from textual import on
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Button, Input, Select, Static
from ..errors import ProxmoxError
from ..models.workflow import WorkflowState
from ..services.base import ProxmoxService, Realm
from ..settings import AppSettings
class LoginView(Vertical):
class Authenticated(Message):
def __init__(self, username: str, realm: str) -> None:
self.username = username
self.realm = realm
super().__init__()
DEFAULT_CSS = """
LoginView {
width: 1fr;
height: 1fr;
padding: 1 2;
align-horizontal: center;
}
#login-card {
width: 80;
max-width: 100%;
border: round $accent;
padding: 1 2;
}
#title {
text-style: bold;
margin-bottom: 1;
}
Input, Select, Button {
margin-top: 1;
}
#status {
margin-top: 1;
color: $text-muted;
}
"""
def __init__(
self,
settings: AppSettings,
workflow: WorkflowState,
service: ProxmoxService,
) -> None:
super().__init__()
self._settings = settings
self._workflow = workflow
self._service = service
def compose(self):
with Vertical(id="login-card"):
yield Static("Proxmox Login", id="title")
yield Static(
f"Mode: {self._service.mode} on {self._settings.sanitized_host}",
id="mode",
)
yield Input(
value=self._settings.proxmox_user or "",
placeholder="Username",
id="username",
)
yield Input(
value=self._settings.proxmox_password or "",
password=True,
placeholder="Password",
id="password",
)
yield Select[str](options=[], prompt="Realm", id="realm")
yield Button("Connect", id="connect", variant="primary")
yield Static("Loading realms...", id="status")
def on_mount(self) -> None:
username_input = self.query_one("#username", Input)
self.call_after_refresh(self.app.set_focus, username_input)
self.run_worker(self._load_realms, thread=True, exclusive=True)
def _load_realms(self) -> None:
try:
realms = self._service.load_realms()
except Exception as exc:
self.app.call_from_thread(self._show_status, f"Failed to load realms: {exc}")
return
self.app.call_from_thread(self._set_realms, realms)
def _set_realms(self, realms: list[Realm]) -> None:
self._workflow.available_realms = [realm.name for realm in realms]
options = [(realm.title, realm.name) for realm in realms]
select = self.query_one("#realm", Select)
select.set_options(options)
preferred_realm = self._settings.proxmox_realm
if preferred_realm and preferred_realm in self._workflow.available_realms:
select.value = preferred_realm
elif realms:
default_realm = next((realm.name for realm in realms if realm.default), realms[0].name)
select.value = default_realm
self._show_status(f"Loaded {len(realms)} realm(s).")
def _show_status(self, message: str) -> None:
self.query_one("#status", Static).update(message)
@on(Button.Pressed, "#connect")
def on_connect_pressed(self) -> None:
self._submit()
@on(Input.Submitted, "#username")
@on(Input.Submitted, "#password")
def on_input_submitted(self) -> None:
self._submit()
@on(Select.Changed, "#realm")
def on_realm_changed(self) -> None:
# Keep the form keyboard friendly once realms have loaded.
if self.app.focused is None:
username_input = self.query_one("#username", Input)
self.call_after_refresh(self.app.set_focus, username_input)
def _submit(self) -> None:
username = self.query_one("#username", Input).value.strip()
password = self.query_one("#password", Input).value
realm = self.query_one("#realm", Select).value
if not username or not password or not isinstance(realm, str):
self._show_status("Username, password, and realm are required.")
return
self._show_status("Authenticating...")
self.run_worker(
lambda: self._authenticate(username=username, password=password, realm=realm),
thread=True,
exclusive=True,
)
def _authenticate(self, *, username: str, password: str, realm: str) -> None:
try:
session = self._service.login(username, password, realm)
except (ProxmoxError, ValueError) as exc:
self.app.call_from_thread(self._show_status, f"Authentication failed: {exc}")
return
self.app.call_from_thread(self._mark_authenticated, session.username, realm)
def _mark_authenticated(self, username: str, realm: str) -> None:
self._workflow.authentication.username = username
self._workflow.authentication.realm = realm
self._workflow.authentication.authenticated = True
self._show_status(f"Authenticated as {username}.")
self.post_message(self.Authenticated(username, realm))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
"""Service layer for Proxmox access."""

View file

@ -0,0 +1,90 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from ..models.workflow import VmConfig
@dataclass(frozen=True)
class Realm:
name: str
title: str
default: bool = False
@dataclass(frozen=True)
class AuthenticatedSession:
username: str
ticket: str
csrf_token: str | None = None
@dataclass(frozen=True)
class Node:
name: str
status: str | None = None
@dataclass(frozen=True)
class Pool:
poolid: str
comment: str | None = None
@dataclass(frozen=True)
class Storage:
storage: str
node: str
content: tuple[str, ...]
@dataclass(frozen=True)
class Bridge:
iface: str
active: bool = True
@dataclass(frozen=True)
class IsoImage:
volid: str
storage: str
node: str
@dataclass(frozen=True)
class VmCreationResult:
node: str
vmid: int
name: str
serial_console_configured: bool = True
ha_configured: bool = False
class ProxmoxService(Protocol):
mode: str
def check_connectivity(self) -> str: ...
def check_api_base(self) -> str: ...
def load_realms(self) -> list[Realm]: ...
def login(self, username: str, password: str, realm: str) -> AuthenticatedSession: ...
def load_nodes(self) -> list[Node]: ...
def load_next_vmid(self) -> int: ...
def load_pools(self) -> list[Pool]: ...
def load_existing_tags(self) -> list[str]: ...
def load_bridges(self, node: str) -> list[Bridge]: ...
def load_storages(self, node: str) -> list[Storage]: ...
def load_isos(self, node: str, storage: str) -> list[IsoImage]: ...
def create_vm(self, config: VmConfig, start_after_create: bool = False) -> VmCreationResult: ...

View file

@ -0,0 +1,12 @@
from ..settings import AppSettings
from .base import ProxmoxService
from .fake import FakeProxmoxService
from .proxmox import LiveProxmoxService, ProxmoxApiClient
class ProxmoxServiceFactory:
@staticmethod
def create(settings: AppSettings) -> ProxmoxService:
if settings.is_live_configured:
return LiveProxmoxService(ProxmoxApiClient(settings))
return FakeProxmoxService()

View file

@ -0,0 +1,82 @@
from ..models.workflow import VmConfig
from .base import (
AuthenticatedSession,
Bridge,
IsoImage,
Node,
Pool,
ProxmoxService,
Realm,
Storage,
VmCreationResult,
)
class FakeProxmoxService(ProxmoxService):
mode = "fake"
def __init__(self) -> None:
self.created_vms: list[VmCreationResult] = []
self.start_after_create_requests: list[bool] = []
def check_connectivity(self) -> str:
return "fake transport reachable"
def check_api_base(self) -> str:
return "fake-api-base"
def load_realms(self) -> list[Realm]:
return [
Realm(name="pam", title="Linux PAM standard authentication", default=True),
Realm(name="pve", title="Proxmox VE authentication server"),
]
def login(self, username: str, password: str, realm: str) -> AuthenticatedSession:
if not username or not password:
raise ValueError("Username and password are required.")
return AuthenticatedSession(username=f"{username}@{realm}", ticket="fake-ticket")
def load_nodes(self) -> list[Node]:
return [Node(name="fake-node-01", status="online")]
def load_next_vmid(self) -> int:
return 123
def load_pools(self) -> list[Pool]:
return [Pool(poolid="lab"), Pool(poolid="sandbox")]
def load_existing_tags(self) -> list[str]:
return ["codex-e2e", "linux", "test"]
def load_bridges(self, node: str) -> list[Bridge]:
return [Bridge(iface="vmbr9"), Bridge(iface="vmbr0")]
def load_storages(self, node: str) -> list[Storage]:
return [
Storage(storage="cephfs", node=node, content=("iso", "backup")),
Storage(storage="ceph-pool", node=node, content=("images",)),
]
def load_isos(self, node: str, storage: str) -> list[IsoImage]:
return [
IsoImage(
volid=f"{storage}:iso/nixos-minimal-24-11.1234abcd-x86_64-linux.iso",
storage=storage,
node=node,
)
]
def create_vm(self, config: VmConfig, start_after_create: bool = False) -> VmCreationResult:
name = config.general.name
if not name.startswith("codex-e2e-"):
name = f"codex-e2e-{name}"
self.start_after_create_requests.append(start_after_create)
result = VmCreationResult(
node=config.general.node,
vmid=config.general.vmid,
name=name,
serial_console_configured=True,
ha_configured=config.general.ha_enabled,
)
self.created_vms.append(result)
return result

View file

@ -0,0 +1,399 @@
from __future__ import annotations
import time
from collections.abc import Callable
import httpx
from ..domain import build_create_payload
from ..errors import (
ProxmoxApiError,
ProxmoxAuthError,
ProxmoxConnectError,
ProxmoxError,
ProxmoxPostCreateError,
ProxmoxTlsError,
ProxmoxTransportError,
ProxmoxUnexpectedResponseError,
)
from ..models.workflow import VmConfig
from ..settings import AppSettings
from .base import (
AuthenticatedSession,
Bridge,
IsoImage,
Node,
Pool,
ProxmoxService,
Realm,
Storage,
VmCreationResult,
)
def _looks_like_tls_error(message: str) -> bool:
upper = message.upper()
indicators = ("SSL", "TLS", "CERTIFICATE", "WRONG_VERSION", "EOF")
return any(token in upper for token in indicators)
class ProxmoxApiClient:
def __init__(
self,
settings: AppSettings,
*,
transport: httpx.BaseTransport | None = None,
client_factory: Callable[..., httpx.Client] | None = None,
) -> None:
settings.validate_live_requirements()
self._settings = settings
factory = client_factory or httpx.Client
self._client = factory(
base_url=settings.api_url,
verify=settings.proxmox_verify_tls,
timeout=settings.request_timeout_seconds,
follow_redirects=True,
transport=transport,
)
self._ticket: str | None = None
self._csrf_token: str | None = None
def close(self) -> None:
self._client.close()
def probe_transport(self) -> str:
try:
response = self._client.get(self._settings.proxmox_url or "/")
return f"HTTP {response.status_code}"
except httpx.ConnectError as exc:
raise ProxmoxConnectError("Unable to connect to the Proxmox host.") from exc
except httpx.TransportError as exc:
message = str(exc)
if _looks_like_tls_error(message):
raise ProxmoxTlsError("TLS handshake or verification failed.") from exc
raise ProxmoxTransportError("Transport error while probing Proxmox.") from exc
def check_api_base(self) -> str:
payload = self._request_json("GET", "/access/domains")
if not isinstance(payload, list):
raise ProxmoxUnexpectedResponseError("API base check did not return a realms list.")
return "access/domains"
def login(self, username: str, password: str, realm: str) -> AuthenticatedSession:
full_username = username if "@" in username else f"{username}@{realm}"
payload = self._request_json(
"POST",
"/access/ticket",
data={"username": full_username, "password": password},
)
ticket = payload.get("ticket")
csrf_token = payload.get("CSRFPreventionToken")
if not isinstance(ticket, str) or not ticket:
raise ProxmoxUnexpectedResponseError("Login response did not include a ticket.")
self._ticket = ticket
self._csrf_token = csrf_token if isinstance(csrf_token, str) else None
self._client.cookies.set("PVEAuthCookie", ticket)
return AuthenticatedSession(
username=full_username,
ticket=ticket,
csrf_token=self._csrf_token,
)
def load_realms(self) -> list[Realm]:
payload = self._request_json("GET", "/access/domains")
realms: list[Realm] = []
if not isinstance(payload, list):
raise ProxmoxUnexpectedResponseError("Realms payload was not a list.")
for item in payload:
if not isinstance(item, dict):
continue
realm = item.get("realm")
title = item.get("comment") or item.get("commentary") or realm
if isinstance(realm, str) and isinstance(title, str):
realms.append(
Realm(
name=realm,
title=title,
default=bool(item.get("default")),
)
)
return realms
def load_nodes(self) -> list[Node]:
payload = self._request_json("GET", "/nodes", requires_auth=True)
if not isinstance(payload, list):
raise ProxmoxUnexpectedResponseError("Nodes payload was not a list.")
return [
Node(name=item["node"], status=item.get("status"))
for item in payload
if isinstance(item, dict) and isinstance(item.get("node"), str)
]
def load_next_vmid(self) -> int:
payload = self._request_json("GET", "/cluster/nextid", requires_auth=True)
if isinstance(payload, int):
return payload
if isinstance(payload, str) and payload.isdigit():
return int(payload)
raise ProxmoxUnexpectedResponseError("Next VM ID payload was not an integer.")
def load_pools(self) -> list[Pool]:
payload = self._request_json("GET", "/pools", requires_auth=True)
if not isinstance(payload, list):
raise ProxmoxUnexpectedResponseError("Pools payload was not a list.")
return [
Pool(poolid=item["poolid"], comment=item.get("comment"))
for item in payload
if isinstance(item, dict) and isinstance(item.get("poolid"), str)
]
def load_existing_tags(self) -> list[str]:
payload = self._request_json(
"GET",
"/cluster/resources",
params={"type": "vm"},
requires_auth=True,
)
if not isinstance(payload, list):
raise ProxmoxUnexpectedResponseError("Cluster resource payload was not a list.")
tags: set[str] = set()
for item in payload:
if not isinstance(item, dict):
continue
raw_tags = item.get("tags")
if not isinstance(raw_tags, str):
continue
for tag in raw_tags.replace(",", ";").split(";"):
normalized = tag.strip()
if normalized:
tags.add(normalized)
return sorted(tags)
def load_bridges(self, node: str) -> list[Bridge]:
payload = self._request_json("GET", f"/nodes/{node}/network", requires_auth=True)
if not isinstance(payload, list):
raise ProxmoxUnexpectedResponseError("Network payload was not a list.")
bridges = [
Bridge(iface=item["iface"], active=bool(item.get("active", True)))
for item in payload
if isinstance(item, dict)
and item.get("type") == "bridge"
and isinstance(item.get("iface"), str)
]
return sorted(bridges, key=lambda bridge: bridge.iface)
def load_storages(self, node: str) -> list[Storage]:
payload = self._request_json("GET", f"/nodes/{node}/storage", requires_auth=True)
if not isinstance(payload, list):
raise ProxmoxUnexpectedResponseError("Storage payload was not a list.")
storages: list[Storage] = []
for item in payload:
if not isinstance(item, dict):
continue
storage = item.get("storage")
content = tuple(
part.strip()
for part in str(item.get("content", "")).split(",")
if part and isinstance(part, str)
)
if isinstance(storage, str):
storages.append(Storage(storage=storage, node=node, content=content))
return storages
def load_isos(self, node: str, storage: str) -> list[IsoImage]:
payload = self._request_json(
"GET",
f"/nodes/{node}/storage/{storage}/content",
params={"content": "iso"},
requires_auth=True,
)
if not isinstance(payload, list):
raise ProxmoxUnexpectedResponseError("ISO payload was not a list.")
return [
IsoImage(volid=item["volid"], storage=storage, node=node)
for item in payload
if isinstance(item, dict) and isinstance(item.get("volid"), str)
]
def create_vm(self, config: VmConfig, start_after_create: bool = False) -> VmCreationResult:
payload = build_create_payload(config, self._settings)
node = config.general.node
vmid = config.general.vmid
name = payload["name"]
upid = self._request_json(
"POST",
f"/nodes/{node}/qemu",
data={key: str(value) for key, value in payload.items()},
requires_auth=True,
)
if isinstance(upid, str) and upid.startswith("UPID:"):
self._wait_for_task(node, upid)
try:
serial_result = self._request_json(
"PUT",
f"/nodes/{node}/qemu/{vmid}/config",
data={"serial0": "socket"},
requires_auth=True,
)
if isinstance(serial_result, str) and serial_result.startswith("UPID:"):
self._wait_for_task(node, serial_result)
except ProxmoxError as exc:
raise ProxmoxPostCreateError(
node,
vmid,
"serial-console",
f"VM was created but serial console configuration failed: {exc}",
) from exc
if config.general.ha_enabled:
try:
ha_result = self._request_json(
"POST",
"/cluster/ha/resources",
data={
"sid": f"vm:{vmid}",
"state": "started" if start_after_create else "stopped",
},
requires_auth=True,
)
if isinstance(ha_result, str) and ha_result.startswith("UPID:"):
self._wait_for_task(node, ha_result)
except ProxmoxError as exc:
raise ProxmoxPostCreateError(
node,
vmid,
"high-availability",
f"VM was created but HA configuration failed: {exc}",
) from exc
elif start_after_create:
try:
start_result = self._request_json(
"POST",
f"/nodes/{node}/qemu/{vmid}/status/start",
requires_auth=True,
)
if isinstance(start_result, str) and start_result.startswith("UPID:"):
self._wait_for_task(node, start_result)
except ProxmoxError as exc:
raise ProxmoxPostCreateError(
node,
vmid,
"start",
f"VM was created but automatic start failed: {exc}",
) from exc
return VmCreationResult(
node=node,
vmid=vmid,
name=str(name),
serial_console_configured=True,
ha_configured=config.general.ha_enabled,
)
def _wait_for_task(self, node: str, upid: str) -> None:
deadline = time.time() + self._settings.request_timeout_seconds
while time.time() < deadline:
payload = self._request_json(
"GET",
f"/nodes/{node}/tasks/{upid}/status",
requires_auth=True,
)
if not isinstance(payload, dict):
raise ProxmoxUnexpectedResponseError("Task status payload was not an object.")
if payload.get("status") == "stopped":
if payload.get("exitstatus") != "OK":
raise ProxmoxApiError(f"Task {upid} failed with {payload.get('exitstatus')}.")
return
time.sleep(0.5)
raise ProxmoxTransportError(f"Timed out while waiting for task {upid}.")
def _request_json(
self,
method: str,
path: str,
*,
requires_auth: bool = False,
**kwargs: object,
) -> object:
headers: dict[str, str] = {}
if requires_auth:
if not self._ticket:
raise ProxmoxAuthError("Not authenticated with Proxmox.")
if method.upper() not in {"GET", "HEAD", "OPTIONS"} and self._csrf_token:
headers["CSRFPreventionToken"] = self._csrf_token
try:
response = self._client.request(method, path, headers=headers, **kwargs)
response.raise_for_status()
except httpx.ConnectError as exc:
raise ProxmoxConnectError("Unable to connect to the Proxmox API.") from exc
except httpx.TransportError as exc:
message = str(exc)
if _looks_like_tls_error(message):
raise ProxmoxTlsError("TLS handshake or verification failed.") from exc
raise ProxmoxTransportError("Transport error while calling the Proxmox API.") from exc
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code
if status_code in {401, 403}:
raise ProxmoxAuthError("Authentication was rejected by Proxmox.") from exc
raise ProxmoxApiError(
f"Proxmox API returned HTTP {status_code}.",
status_code=status_code,
) from exc
try:
payload = response.json()
except ValueError as exc:
raise ProxmoxUnexpectedResponseError("Expected a JSON response from Proxmox.") from exc
if not isinstance(payload, dict) or "data" not in payload:
raise ProxmoxUnexpectedResponseError("Expected a top-level data field in the response.")
return payload["data"]
class LiveProxmoxService(ProxmoxService):
mode = "live"
def __init__(self, client: ProxmoxApiClient) -> None:
self._client = client
def check_connectivity(self) -> str:
return self._client.probe_transport()
def check_api_base(self) -> str:
return self._client.check_api_base()
def load_realms(self) -> list[Realm]:
return self._client.load_realms()
def login(self, username: str, password: str, realm: str) -> AuthenticatedSession:
return self._client.login(username, password, realm)
def load_nodes(self) -> list[Node]:
return self._client.load_nodes()
def load_next_vmid(self) -> int:
return self._client.load_next_vmid()
def load_pools(self) -> list[Pool]:
return self._client.load_pools()
def load_existing_tags(self) -> list[str]:
return self._client.load_existing_tags()
def load_bridges(self, node: str) -> list[Bridge]:
return self._client.load_bridges(node)
def load_storages(self, node: str) -> list[Storage]:
return self._client.load_storages(node)
def load_isos(self, node: str, storage: str) -> list[IsoImage]:
return self._client.load_isos(node, storage)
def create_vm(self, config: VmConfig, start_after_create: bool = False) -> VmCreationResult:
return self._client.create_vm(config, start_after_create=start_after_create)
def close(self) -> None:
self._client.close()

View file

@ -0,0 +1,179 @@
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
from dotenv import dotenv_values
from .errors import SettingsError
def _parse_bool(value: str | None, *, default: bool) -> bool:
if value is None or value == "":
return default
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise SettingsError(f"Invalid boolean value: {value!r}")
def _parse_int(value: str | None, *, default: int) -> int:
if value is None or value == "":
return default
try:
return int(value)
except ValueError as exc:
raise SettingsError(f"Invalid integer value: {value!r}") from exc
@dataclass(frozen=True)
class LiveSafetyPolicy:
prevent_create: bool
enable_test_mode: bool
test_node: str | None
test_pool: str | None
test_tag: str
test_vm_name_prefix: str
keep_failed_vm: bool
def validate(self) -> None:
if self.enable_test_mode:
if not self.test_node:
raise SettingsError(
"PROXMOX_TEST_NODE is required when PROXMOX_ENABLE_TEST_MODE=true."
)
if not self.test_pool:
raise SettingsError(
"PROXMOX_TEST_POOL is required when PROXMOX_ENABLE_TEST_MODE=true."
)
if not self.test_tag:
raise SettingsError(
"PROXMOX_TEST_TAG is required when PROXMOX_ENABLE_TEST_MODE=true."
)
if not self.test_vm_name_prefix:
raise SettingsError(
"PROXMOX_TEST_VM_NAME_PREFIX is required when "
"PROXMOX_ENABLE_TEST_MODE=true."
)
@property
def allow_create(self) -> bool:
return not self.prevent_create
def effective_vm_name(self, requested_name: str) -> str:
if not self.enable_test_mode:
return requested_name
if requested_name.startswith(self.test_vm_name_prefix):
return requested_name
return f"{self.test_vm_name_prefix}{requested_name}"
@dataclass(frozen=True)
class AppSettings:
proxmox_url: str | None
proxmox_api_base: str
proxmox_user: str | None
proxmox_password: str | None
proxmox_realm: str | None
proxmox_verify_tls: bool
request_timeout_seconds: int
safety_policy: LiveSafetyPolicy
@classmethod
def from_env(
cls,
env: Mapping[str, str] | None = None,
*,
load_dotenv_file: bool = True,
dotenv_path: str | Path = ".env",
) -> 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
}
)
raw.update(os.environ if env is None else env)
api_base = raw.get("PROXMOX_API_BASE", "/api2/json").strip() or "/api2/json"
if not api_base.startswith("/"):
api_base = f"/{api_base}"
safety_policy = LiveSafetyPolicy(
prevent_create=_parse_bool(raw.get("PROXMOX_PREVENT_CREATE"), default=False),
enable_test_mode=_parse_bool(raw.get("PROXMOX_ENABLE_TEST_MODE"), default=False),
test_node=raw.get("PROXMOX_TEST_NODE") or None,
test_pool=raw.get("PROXMOX_TEST_POOL") or None,
test_tag=raw.get("PROXMOX_TEST_TAG", "codex-e2e").strip() or "codex-e2e",
test_vm_name_prefix=raw.get("PROXMOX_TEST_VM_NAME_PREFIX", "codex-e2e-").strip()
or "codex-e2e-",
keep_failed_vm=_parse_bool(raw.get("PROXMOX_KEEP_FAILED_VM"), default=True),
)
safety_policy.validate()
proxmox_url = (raw.get("PROXMOX_URL") or "").strip() or None
if proxmox_url is not None:
proxmox_url = proxmox_url.rstrip("/")
return cls(
proxmox_url=proxmox_url,
proxmox_api_base=api_base,
proxmox_user=(raw.get("PROXMOX_USER") or "").strip() or None,
proxmox_password=raw.get("PROXMOX_PASSWORD") or None,
proxmox_realm=(raw.get("PROXMOX_REALM") or "").strip() or None,
proxmox_verify_tls=_parse_bool(raw.get("PROXMOX_VERIFY_TLS"), default=False),
request_timeout_seconds=_parse_int(
raw.get("PROXMOX_REQUEST_TIMEOUT_SECONDS"), default=15
),
safety_policy=safety_policy,
)
@property
def is_live_configured(self) -> bool:
return bool(self.proxmox_url and self.proxmox_user and self.proxmox_password)
@property
def effective_username(self) -> str | None:
if not self.proxmox_user or not self.proxmox_realm:
return None
if "@" in self.proxmox_user:
return self.proxmox_user
return f"{self.proxmox_user}@{self.proxmox_realm}"
@property
def sanitized_host(self) -> str:
if not self.proxmox_url:
return "not-configured"
parsed = urlparse(self.proxmox_url)
host = parsed.hostname or parsed.netloc or self.proxmox_url
if parsed.port:
return f"{host}:{parsed.port}"
return host
@property
def api_url(self) -> str:
if not self.proxmox_url:
raise SettingsError("PROXMOX_URL is required for live Proxmox access.")
return f"{self.proxmox_url}{self.proxmox_api_base}"
def validate_live_requirements(self) -> None:
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:
missing.append("PROXMOX_PASSWORD")
if not self.proxmox_realm:
missing.append("PROXMOX_REALM")
if missing:
joined = ", ".join(missing)
raise SettingsError(f"Missing live Proxmox configuration: {joined}.")

View file

@ -0,0 +1,140 @@
from __future__ import annotations
import asyncio
import os
import signal
import sys
import termios
import tty
from threading import Thread
from textual import events
from textual.driver import Driver
from textual.geometry import Size
from textual.messages import TerminalSupportInBandWindowResize
def build_driver_class() -> type[Driver] | None:
"""Return an opt-in compatibility driver for problematic terminals.
Textual's stock driver is the default because it is the best-tested path.
The compatibility driver remains available behind an env flag for targeted
debugging only.
"""
if os.getenv("YOUR_APP_ENABLE_COMPAT_DRIVER", "").lower() not in {"1", "true", "yes"}:
return None
if sys.platform.startswith("win"):
return None
from textual.drivers._writer_thread import WriterThread
from textual.drivers.linux_driver import LinuxDriver
class CompatLinuxDriver(LinuxDriver):
"""Terminal driver with advanced terminal features disabled.
This avoids terminal-specific issues around Kitty keyboard mode,
mouse tracking, sync mode probing, and bracketed paste.
"""
def start_application_mode(self) -> None:
def _stop_again(*_) -> None:
os.kill(os.getpid(), signal.SIGSTOP)
if os.isatty(self.fileno):
signal.signal(signal.SIGTTOU, _stop_again)
signal.signal(signal.SIGTTIN, _stop_again)
try:
termios.tcsetattr(
self.fileno, termios.TCSANOW, termios.tcgetattr(self.fileno)
)
except termios.error:
return
finally:
signal.signal(signal.SIGTTOU, signal.SIG_DFL)
signal.signal(signal.SIGTTIN, signal.SIG_DFL)
loop = asyncio.get_running_loop()
def send_size_event() -> None:
width, height = self._get_terminal_size()
textual_size = Size(width, height)
event = events.Resize(textual_size, textual_size)
asyncio.run_coroutine_threadsafe(self._app._post_message(event), loop=loop)
self._writer_thread = WriterThread(self._file)
self._writer_thread.start()
def on_terminal_resize(signum, stack) -> None:
if not self._in_band_window_resize:
send_size_event()
signal.signal(signal.SIGWINCH, on_terminal_resize)
self.write("\x1b[?1049h")
try:
self.attrs_before = termios.tcgetattr(self.fileno)
except termios.error:
self.attrs_before = None
try:
newattr = termios.tcgetattr(self.fileno)
except termios.error:
pass
else:
newattr[tty.LFLAG] = self._patch_lflag(newattr[tty.LFLAG])
newattr[tty.IFLAG] = self._patch_iflag(newattr[tty.IFLAG])
newattr[tty.CC][termios.VMIN] = 1
try:
termios.tcsetattr(self.fileno, termios.TCSANOW, newattr)
except termios.error:
pass
self.write("\x1b[?25l")
self.flush()
self._key_thread = Thread(target=self._run_input_thread)
send_size_event()
self._key_thread.start()
self._disable_line_wrap()
if self._must_signal_resume:
self._must_signal_resume = False
asyncio.run_coroutine_threadsafe(
self._app._post_message(self.SignalResume()),
loop=loop,
)
def stop_application_mode(self) -> None:
self._enable_line_wrap()
self.disable_input()
if self.attrs_before is not None:
try:
termios.tcsetattr(self.fileno, termios.TCSANOW, self.attrs_before)
except termios.error:
pass
self.write("\x1b[?1049l")
self.write("\x1b[?25h")
self.flush()
def _request_terminal_sync_mode_support(self) -> None:
return
def _disable_in_band_window_resize(self) -> None:
self._in_band_window_resize = False
async def _on_terminal_supports_in_band_window_resize(
self, message: TerminalSupportInBandWindowResize
) -> None:
self._in_band_window_resize = False
return CompatLinuxDriver
def apply_runtime_compatibility() -> None:
os.environ.setdefault("TEXTUAL_ALLOW_SIGNALS", "1")
signal.signal(signal.SIGINT, signal.default_int_handler)

View file

@ -0,0 +1 @@
"""Reusable Textual widgets."""

16
tests/conftest.py Normal file
View file

@ -0,0 +1,16 @@
from __future__ import annotations
import pytest
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
markexpr = (config.option.markexpr or "").strip()
if markexpr:
return
skip_live = pytest.mark.skip(
reason="Live tests run only via `pytest -m live` or `pytest -m live_create`."
)
for item in items:
if "live" in item.keywords or "live_create" in item.keywords:
item.add_marker(skip_live)

View file

@ -0,0 +1,59 @@
from __future__ import annotations
import pytest
from pve_vm_setup.services.factory import ProxmoxServiceFactory
from pve_vm_setup.settings import AppSettings
def _load_live_settings_or_skip() -> AppSettings:
settings = AppSettings.from_env()
try:
settings.validate_live_requirements()
except Exception as exc: # pragma: no cover - only hit outside configured environments
pytest.skip(f"Live environment is not configured: {exc}")
return settings
@pytest.mark.live
def test_live_read_only_reference_loading() -> None:
settings = _load_live_settings_or_skip()
service = ProxmoxServiceFactory.create(settings)
assert service.mode == "live"
assert service.check_connectivity()
assert service.check_api_base()
realms = service.load_realms()
assert realms
service.login(
settings.proxmox_user or "",
settings.proxmox_password or "",
settings.proxmox_realm or "",
)
nodes = service.load_nodes()
assert nodes
pools = service.load_pools()
assert isinstance(pools, list)
tags = service.load_existing_tags()
assert isinstance(tags, list)
probe_node = settings.safety_policy.test_node or nodes[0].name
storages = service.load_storages(probe_node)
assert isinstance(storages, list)
iso_storages = [storage for storage in storages if "iso" in storage.content]
if iso_storages:
isos = service.load_isos(probe_node, iso_storages[0].storage)
assert isinstance(isos, list)
@pytest.mark.live_create
def test_live_create_path_requires_explicit_opt_in() -> None:
settings = _load_live_settings_or_skip()
if not settings.safety_policy.allow_create:
pytest.skip("Set PROXMOX_PREVENT_CREATE=false to enable live create tests.")
if settings.safety_policy.enable_test_mode:
assert settings.safety_policy.test_node

590
tests/test_app.py Normal file
View file

@ -0,0 +1,590 @@
from __future__ import annotations
import asyncio
import time
from collections import Counter
import pytest
from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer
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.login import LoginView
from pve_vm_setup.screens.wizard import NO_DISK_SELECTED, AutoStartConfirmModal, WizardView
from pve_vm_setup.services.fake import FakeProxmoxService
from pve_vm_setup.settings import AppSettings
class LoginHarnessApp(App[None]):
def compose(self) -> ComposeResult:
yield LoginView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
FakeProxmoxService(),
)
async def wait_for_wizard_ready(
pilot,
app: App[None],
*,
attempts: int = 12,
delay: float = 0.1,
) -> None:
for _ in range(attempts):
await pilot.pause(delay)
if (
app.query_one("#general-vmid", Input).value == "123"
and app.query_one("#general-node", Select).value == "fake-node-01"
and app.query_one("#os-storage", Select).value == "cephfs"
):
return
raise AssertionError("Timed out waiting for wizard reference data to load.")
@pytest.mark.asyncio
async def test_login_view_authenticates_with_pilot() -> None:
app = LoginHarnessApp()
async with app.run_test() as pilot:
await pilot.pause()
assert str(app.query_one("#title", Static).renderable) == "Proxmox Login"
assert app.focused is app.query_one("#username", Input)
app.query_one("#username", Input).value = "junior"
app.query_one("#password", Input).value = "secret"
app.query_one("#connect", Button).press()
await pilot.pause()
assert "Authenticated as junior@pam." == str(app.query_one("#status", Static).renderable)
@pytest.mark.asyncio
async def test_main_app_mounts_wizard_only_after_login() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
await pilot.pause()
assert app.query(LoginView)
assert not app.query(WizardView)
login = app.query_one(LoginView)
login.post_message(LoginView.Authenticated("junior@pam", "pam"))
await pilot.pause()
await pilot.pause()
await wait_for_wizard_ready(pilot, app)
assert not app.query(LoginView)
assert app.query(WizardView)
assert app.focused is app.query_one("#general-name", Input)
@pytest.mark.asyncio
async def test_wizard_activation_focuses_first_editable_field() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
assert app.focused is app.query_one("#general-name", Input)
@pytest.mark.asyncio
async def test_wizard_initial_activation_does_not_duplicate_live_reference_loads() -> None:
class CountingService(FakeProxmoxService):
def __init__(self) -> None:
super().__init__()
self.calls: list[str] = []
def load_nodes(self):
self.calls.append("load_nodes")
return super().load_nodes()
def load_pools(self):
self.calls.append("load_pools")
return super().load_pools()
def load_existing_tags(self):
self.calls.append("load_existing_tags")
return super().load_existing_tags()
def load_next_vmid(self):
self.calls.append("load_next_vmid")
return super().load_next_vmid()
def load_storages(self, node: str):
self.calls.append(f"load_storages:{node}")
return super().load_storages(node)
def load_bridges(self, node: str):
self.calls.append(f"load_bridges:{node}")
return super().load_bridges(node)
def load_isos(self, node: str, storage: str):
self.calls.append(f"load_isos:{node}:{storage}")
return super().load_isos(node, storage)
service = CountingService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
for _ in range(6):
await pilot.pause()
assert Counter(service.calls) == Counter(
[
"load_nodes",
"load_pools",
"load_existing_tags",
"load_next_vmid",
"load_storages:fake-node-01",
"load_bridges:fake-node-01",
"load_isos:fake-node-01:cephfs",
]
)
@pytest.mark.asyncio
async def test_wizard_initial_activation_loads_reference_data_concurrently() -> None:
class SlowService(FakeProxmoxService):
delay = 0.15
def load_nodes(self):
time.sleep(self.delay)
return super().load_nodes()
def load_pools(self):
time.sleep(self.delay)
return super().load_pools()
def load_existing_tags(self):
time.sleep(self.delay)
return super().load_existing_tags()
def load_next_vmid(self):
time.sleep(self.delay)
return super().load_next_vmid()
def load_storages(self, node: str):
time.sleep(self.delay)
return super().load_storages(node)
def load_bridges(self, node: str):
time.sleep(self.delay)
return super().load_bridges(node)
def load_isos(self, node: str, storage: str):
time.sleep(self.delay)
return super().load_isos(node, storage)
service = SlowService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
started_at = time.perf_counter()
wizard.activate()
await wait_for_wizard_ready(pilot, app)
elapsed = time.perf_counter() - started_at
assert elapsed < 1.0
@pytest.mark.asyncio
async def test_wizard_uses_scrollable_sections_with_border_titles() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
general_section = app.query_one("#general-section", ScrollableContainer)
os_section = app.query_one("#os-section", ScrollableContainer)
assert str(general_section.border_title).strip() == "General"
assert str(os_section.border_title).strip() == "Operating System"
@pytest.mark.asyncio
async def test_wizard_hides_os_fields_based_on_media_choice() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
assert app.query_one("#os-storage", Select).display is True
assert app.query_one("#os-iso", Select).display is True
assert app.query_one("#os-physical-drive", Input).display is False
app.query_one("#os-media-choice", Select).value = "physical"
await pilot.pause()
assert app.query_one("#os-storage", Select).display is False
assert app.query_one("#os-iso", Select).display is False
assert app.query_one("#os-physical-drive", Input).display is True
app.query_one("#os-media-choice", Select).value = "none"
await pilot.pause()
assert app.query_one("#os-storage", Select).display is False
assert app.query_one("#os-iso", Select).display is False
assert app.query_one("#os-physical-drive", Input).display is False
@pytest.mark.asyncio
async def test_wizard_hides_dependent_system_memory_and_network_fields() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
assert app.query_one("#system-efi-storage", Select).display is True
assert app.query_one("#system-pre-enroll", Checkbox).display is True
app.query_one("#system-add-efi", Checkbox).value = False
await pilot.pause()
assert app.query_one("#system-efi-storage", Select).display is False
assert app.query_one("#system-pre-enroll", Checkbox).display is False
app.query_one("#system-tpm", Checkbox).value = True
await pilot.pause()
assert app.query_one("#system-efi-storage", Select).display is True
assert app.query_one("#system-pre-enroll", Checkbox).display is False
assert app.query_one("#memory-min-size", Input).display is True
assert app.query_one("#memory-ksm", Checkbox).display is True
app.query_one("#memory-ballooning", Checkbox).value = False
await pilot.pause()
assert app.query_one("#memory-min-size", Input).display is False
assert app.query_one("#memory-ksm", Checkbox).display is False
assert app.query_one("#network-bridge", Select).display is True
assert app.query_one("#network-rate", Input).display is True
app.query_one("#network-none", Checkbox).value = True
await pilot.pause()
assert app.query_one("#network-bridge", Select).display is False
assert app.query_one("#network-rate", Input).display is False
@pytest.mark.asyncio
async def test_wizard_tag_rows_keep_input_and_button_visible() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
assert app.query_one("#general-tag-input", Input).display is True
assert app.query_one("#general-tag-add", Button).display is True
assert app.query_one("#general-tag-existing", Select).display is True
assert app.query_one("#general-tag-use", Button).display is True
@pytest.mark.asyncio
async def test_wizard_add_tag_button_updates_current_tags() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
app.query_one("#general-tag-input", Input).value = "alpha"
app.query_one("#general-tag-add", Button).press()
await pilot.pause()
assert wizard._workflow.config.general.tags == ["alpha"]
current_tags = app.query_one("#general-tag-current", Select)
assert current_tags.display is True
@pytest.mark.asyncio
async def test_wizard_hiding_select_collapses_open_overlay() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
storage = app.query_one("#os-storage", Select)
storage.expanded = True
await pilot.pause()
assert storage.expanded is True
app.query_one("#os-media-choice", Select).value = "physical"
await pilot.pause()
assert storage.display is False
assert storage.expanded is False
@pytest.mark.asyncio
async def test_disk_toolbar_buttons_render_left_of_disk_selector() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
wizard._workflow.current_step_index = 3
wizard._show_step()
await pilot.pause()
add_button = app.query_one("#disks-add", Button)
remove_button = app.query_one("#disks-remove", Button)
selector = app.query_one("#disks-select", Select)
assert add_button.region.x < remove_button.region.x
assert selector.region.x == add_button.region.x
assert selector.region.width > remove_button.region.width * 3
@pytest.mark.asyncio
async def test_disk_selector_switches_between_configured_disks_without_blank_option() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
wizard._workflow.current_step_index = 3
wizard._show_step()
await pilot.pause()
app.query_one("#disks-add", Button).press()
await pilot.pause()
selector = app.query_one("#disks-select", Select)
option_values = [value for _, value in selector._options]
assert NO_DISK_SELECTED not in option_values
assert selector.disabled is False
assert selector.value == "1"
await asyncio.wait_for(pilot.click("#disks-select"), timeout=2)
await pilot.pause()
assert selector.expanded is True
await asyncio.wait_for(pilot.press("up"), timeout=2)
await pilot.pause()
await asyncio.wait_for(pilot.press("enter"), timeout=2)
await pilot.pause()
assert selector.expanded is False
assert selector.value == "0"
assert wizard._selected_disk_index == 0
assert app.focused is selector
@pytest.mark.asyncio
async def test_confirm_step_replaces_create_with_exit_after_success() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
wizard._workflow.current_step_index = 7
wizard._workflow.submission.phase = "success"
wizard._workflow.submission.message = "VM 123 created."
wizard._show_step()
await pilot.pause()
create_button = app.query_one("#wizard-create", Button)
assert str(create_button.label) == "Exit"
assert app.focused is create_button
exited: list[bool] = []
app.exit = lambda *args, **kwargs: exited.append(True) # type: ignore[method-assign]
create_button.press()
await pilot.pause()
assert exited == [True]
@pytest.mark.asyncio
async def test_confirm_step_asks_whether_to_start_vm_before_submitting() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
app.query_one("#general-name", Input).value = "demo"
wizard._workflow.current_step_index = 7
wizard._show_step()
await pilot.pause()
app.query_one("#wizard-create", Button).press()
await pilot.pause()
assert isinstance(app.screen_stack[-1], AutoStartConfirmModal)
assert service.created_vms == []
app.query_one("#auto-start-no", Button).press()
for _ in range(20):
await pilot.pause(0.05)
if service.created_vms:
break
assert len(service.created_vms) == 1
assert service.start_after_create_requests == [False]

94
tests/test_doctor.py Normal file
View file

@ -0,0 +1,94 @@
from __future__ import annotations
from io import StringIO
from pve_vm_setup.doctor import run_live_doctor
from pve_vm_setup.services.base import AuthenticatedSession, Node, Pool, Realm
from pve_vm_setup.settings import AppSettings
class StubDoctorService:
mode = "live"
def check_connectivity(self) -> str:
return "HTTP 200"
def check_api_base(self) -> str:
return "8.2"
def load_realms(self) -> list[Realm]:
return [Realm(name="pam", title="Linux PAM standard authentication", default=True)]
def login(self, username: str, password: str, realm: str) -> AuthenticatedSession:
return AuthenticatedSession(username=f"{username}@{realm}", ticket="ticket")
def load_nodes(self) -> list[Node]:
return [Node(name="pve-test-01")]
def load_pools(self) -> list[Pool]:
return [Pool(poolid="sandbox")]
def load_existing_tags(self) -> list[str]:
return []
def load_storages(self, node: str):
raise AssertionError("not used in doctor")
def load_isos(self, node: str, storage: str):
raise AssertionError("not used in doctor")
class StubFactory:
@staticmethod
def create(settings: AppSettings) -> StubDoctorService:
return StubDoctorService()
def test_doctor_succeeds_and_keeps_secrets_out_of_output() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006",
"PROXMOX_USER": "root",
"PROXMOX_PASSWORD": "super-secret",
"PROXMOX_REALM": "pam",
},
load_dotenv_file=False,
)
stream = StringIO()
exit_code = run_live_doctor(settings, stream=stream, service_factory=StubFactory)
output = stream.getvalue()
assert exit_code == 0
assert "Doctor finished successfully." in output
assert "super-secret" not in output
assert "root@pam" in output
assert "host: proxmox.example.invalid:8006" in output
def test_doctor_validates_create_scope_when_enabled() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006",
"PROXMOX_USER": "root",
"PROXMOX_PASSWORD": "super-secret",
"PROXMOX_REALM": "pam",
"PROXMOX_PREVENT_CREATE": "false",
"PROXMOX_ENABLE_TEST_MODE": "true",
"PROXMOX_TEST_NODE": "pve-test-01",
"PROXMOX_TEST_POOL": "sandbox",
},
load_dotenv_file=False,
)
stream = StringIO()
exit_code = run_live_doctor(settings, stream=stream, service_factory=StubFactory)
output = stream.getvalue()
assert exit_code == 0
assert "prevent_create: False" in output
assert "enable_test_mode: True" in output
assert "node=pve-test-01" in output
assert "pool=sandbox" in output
assert "tag=codex-e2e" in output
assert "name_prefix=codex-e2e-" in output

107
tests/test_domain.py Normal file
View file

@ -0,0 +1,107 @@
from pve_vm_setup.domain import build_create_payload, select_latest_nixos_iso, validate_all_steps
from pve_vm_setup.models.workflow import VmConfig
from pve_vm_setup.settings import AppSettings
def test_select_latest_nixos_iso_prefers_latest_year_month() -> None:
choice = select_latest_nixos_iso(
[
"cephfs:iso/nixos-minimal-24.11.1234abcd-x86_64-linux.iso",
"cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso",
"cephfs:iso/debian-12.iso",
]
)
assert choice == "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso"
def test_build_create_payload_applies_safety_name_tag_and_key_settings() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_PREVENT_CREATE": "false",
"PROXMOX_ENABLE_TEST_MODE": "true",
"PROXMOX_TEST_NODE": "fake-node-01",
"PROXMOX_TEST_POOL": "lab",
},
load_dotenv_file=False,
)
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.general.tags = ["linux"]
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso"
payload = build_create_payload(config, settings)
assert payload["name"] == "codex-e2e-demo"
assert payload["tags"] == "codex-e2e;linux"
assert payload["bios"] == "ovmf"
assert payload["scsihw"] == "virtio-scsi-single"
assert payload["allow-ksm"] == 1
assert payload["net0"] == "model=virtio,bridge=vmbr9,firewall=1,link_down=0"
assert payload["scsi0"] == (
"ceph-pool:32,format=raw,cache=none,discard=ignore,"
"iothread=1,ssd=1,backup=1,replicate=1,aio=io_uring"
)
def test_validate_all_steps_requires_live_create_opt_in() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_PREVENT_CREATE": "true",
},
load_dotenv_file=False,
)
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso"
errors = validate_all_steps(config, settings, references=type("Refs", (), {})())
assert "Set PROXMOX_PREVENT_CREATE=false to enable VM creation." in errors
def test_build_create_payload_leaves_name_and_tags_untouched_outside_test_mode() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_PREVENT_CREATE": "false",
},
load_dotenv_file=False,
)
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.general.tags = ["linux"]
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso"
payload = build_create_payload(config, settings)
assert payload["name"] == "demo"
assert payload["tags"] == "linux"
def test_build_create_payload_can_disable_allow_ksm() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_PREVENT_CREATE": "false",
},
load_dotenv_file=False,
)
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso"
config.memory.allow_ksm = False
payload = build_create_payload(config, settings)
assert payload["allow-ksm"] == 0

30
tests/test_factory.py Normal file
View file

@ -0,0 +1,30 @@
from pve_vm_setup.services.factory import ProxmoxServiceFactory
from pve_vm_setup.services.fake import FakeProxmoxService
from pve_vm_setup.services.proxmox import LiveProxmoxService
from pve_vm_setup.settings import AppSettings
def test_factory_returns_fake_service_when_live_env_is_missing() -> None:
settings = AppSettings.from_env({}, load_dotenv_file=False)
service = ProxmoxServiceFactory.create(settings)
assert isinstance(service, FakeProxmoxService)
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,
)
service = ProxmoxServiceFactory.create(settings)
try:
assert isinstance(service, LiveProxmoxService)
finally:
service.close()

View file

@ -0,0 +1,193 @@
from __future__ import annotations
from urllib.parse import parse_qs
import httpx
import pytest
from pve_vm_setup.errors import ProxmoxConnectError
from pve_vm_setup.models.workflow import VmConfig
from pve_vm_setup.services.proxmox import ProxmoxApiClient
from pve_vm_setup.settings import AppSettings
def build_settings() -> AppSettings:
return AppSettings.from_env(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006",
"PROXMOX_USER": "root",
"PROXMOX_PASSWORD": "secret",
"PROXMOX_REALM": "pam",
},
load_dotenv_file=False,
)
def test_client_uses_api_base_when_loading_realms() -> None:
recorded_urls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
recorded_urls.append(str(request.url))
return httpx.Response(200, json={"data": [{"realm": "pam", "comment": "Linux PAM"}]})
client = ProxmoxApiClient(build_settings(), transport=httpx.MockTransport(handler))
try:
realms = client.load_realms()
finally:
client.close()
assert realms[0].name == "pam"
assert recorded_urls == ["https://proxmox.example.invalid:8006/api2/json/access/domains"]
def test_client_maps_connect_errors() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom", request=request)
client = ProxmoxApiClient(build_settings(), transport=httpx.MockTransport(handler))
try:
with pytest.raises(ProxmoxConnectError):
client.load_realms()
finally:
client.close()
def test_client_attaches_serial_device_without_switching_display_to_serial() -> None:
requests: list[tuple[str, str, bytes]] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path, request.content))
path = request.url.path
if path.endswith("/nodes/fake-node-01/qemu") and request.method == "POST":
return httpx.Response(200, json={"data": "UPID:create"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:create/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
if path.endswith("/nodes/fake-node-01/qemu/123/config") and request.method == "PUT":
return httpx.Response(200, json={"data": "UPID:serial"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:serial/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
raise AssertionError(f"Unexpected request: {request.method} {request.url}")
client = ProxmoxApiClient(build_settings(), transport=httpx.MockTransport(handler))
client._ticket = "ticket"
client._csrf_token = "csrf"
client._client.cookies.set("PVEAuthCookie", "ticket")
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.general.ha_enabled = False
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos.iso"
try:
client.create_vm(config)
finally:
client.close()
serial_request = next(
content
for method, path, content in requests
if method == "PUT" and path.endswith("/nodes/fake-node-01/qemu/123/config")
)
payload = parse_qs(serial_request.decode())
assert payload["serial0"] == ["socket"]
assert "vga" not in payload
def test_client_starts_vm_after_create_when_requested() -> None:
requests: list[tuple[str, str, bytes]] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path, request.content))
path = request.url.path
if path.endswith("/nodes/fake-node-01/qemu") and request.method == "POST":
return httpx.Response(200, json={"data": "UPID:create"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:create/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
if path.endswith("/nodes/fake-node-01/qemu/123/config") and request.method == "PUT":
return httpx.Response(200, json={"data": "UPID:serial"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:serial/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
if path.endswith("/nodes/fake-node-01/qemu/123/status/start") and request.method == "POST":
return httpx.Response(200, json={"data": "UPID:start"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:start/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
raise AssertionError(f"Unexpected request: {request.method} {request.url}")
client = ProxmoxApiClient(build_settings(), transport=httpx.MockTransport(handler))
client._ticket = "ticket"
client._csrf_token = "csrf"
client._client.cookies.set("PVEAuthCookie", "ticket")
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.general.ha_enabled = False
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos.iso"
try:
client.create_vm(config, start_after_create=True)
finally:
client.close()
assert any(
method == "POST" and path.endswith("/nodes/fake-node-01/qemu/123/status/start")
for method, path, _ in requests
)
def test_client_registers_ha_without_start_when_auto_start_disabled() -> None:
requests: list[tuple[str, str, bytes]] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path, request.content))
path = request.url.path
if path.endswith("/nodes/fake-node-01/qemu") and request.method == "POST":
return httpx.Response(200, json={"data": "UPID:create"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:create/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
if path.endswith("/nodes/fake-node-01/qemu/123/config") and request.method == "PUT":
return httpx.Response(200, json={"data": "UPID:serial"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:serial/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
if path.endswith("/cluster/ha/resources") and request.method == "POST":
return httpx.Response(200, json={"data": "UPID:ha"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:ha/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
raise AssertionError(f"Unexpected request: {request.method} {request.url}")
client = ProxmoxApiClient(build_settings(), transport=httpx.MockTransport(handler))
client._ticket = "ticket"
client._csrf_token = "csrf"
client._client.cookies.set("PVEAuthCookie", "ticket")
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.general.ha_enabled = True
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos.iso"
try:
client.create_vm(config, start_after_create=False)
finally:
client.close()
ha_request = next(
content
for method, path, content in requests
if method == "POST" and path.endswith("/cluster/ha/resources")
)
payload = parse_qs(ha_request.decode())
assert payload["state"] == ["stopped"]
assert not any(
method == "POST" and path.endswith("/nodes/fake-node-01/qemu/123/status/start")
for method, path, _ in requests
)

56
tests/test_settings.py Normal file
View file

@ -0,0 +1,56 @@
import pytest
from pve_vm_setup.errors import SettingsError
from pve_vm_setup.settings import AppSettings
def test_settings_load_defaults_and_normalize_api_base() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006/",
"PROXMOX_USER": "root",
"PROXMOX_PASSWORD": "secret",
"PROXMOX_REALM": "pam",
"PROXMOX_API_BASE": "api2/json",
},
load_dotenv_file=False,
)
assert settings.proxmox_url == "https://proxmox.example.invalid:8006"
assert settings.proxmox_api_base == "/api2/json"
assert settings.proxmox_verify_tls is False
assert settings.request_timeout_seconds == 15
assert settings.effective_username == "root@pam"
assert settings.safety_policy.prevent_create is False
assert settings.safety_policy.enable_test_mode is False
assert settings.safety_policy.test_tag == "codex-e2e"
assert settings.safety_policy.test_vm_name_prefix == "codex-e2e-"
def test_settings_reject_test_mode_without_required_scope() -> None:
with pytest.raises(SettingsError):
AppSettings.from_env(
{
"PROXMOX_ENABLE_TEST_MODE": "true",
},
load_dotenv_file=False,
)
def test_settings_allow_create_without_test_scope_when_test_mode_disabled() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_PREVENT_CREATE": "false",
},
load_dotenv_file=False,
)
assert settings.safety_policy.allow_create is True
assert settings.safety_policy.enable_test_mode is False
def test_settings_allow_create_by_default_when_prevent_flag_is_unset() -> None:
settings = AppSettings.from_env({}, load_dotenv_file=False)
assert settings.safety_policy.prevent_create is False
assert settings.safety_policy.allow_create is True

317
uv.lock generated Normal file
View file

@ -0,0 +1,317 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
plugins = [
{ name = "mdit-py-plugins" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pve-vm-setup"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "python-dotenv" },
{ name = "textual" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27,<0.29" },
{ name = "python-dotenv", specifier = ">=1.0,<2.0" },
{ name = "textual", specifier = ">=0.63,<0.90" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.3,<9.0" },
{ name = "pytest-asyncio", specifier = ">=0.24,<1.0" },
{ name = "ruff", specifier = ">=0.9,<1.0" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest-asyncio"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156, upload-time = "2025-03-25T06:22:28.883Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "ruff"
version = "0.15.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
]
[[package]]
name = "textual"
version = "0.89.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify", "plugins"] },
{ name = "platformdirs" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/cb/b3ff0e45d812997a527cb581a4cd602f0b28793450aa26201969fd6ce42c/textual-0.89.1.tar.gz", hash = "sha256:66befe80e2bca5a8c876cd8ceeaf01752267b6b1dc1d0f73071f1f1e15d90cc8", size = 1517074, upload-time = "2024-12-05T15:17:12.903Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/02/650adf160774a43c206011d23283d568d2dbcd43cf7b40dff0a880885b47/textual-0.89.1-py3-none-any.whl", hash = "sha256:0a5d214df6e951b4a2c421e13d0b608482882471c1e34ea74a3631adede8054f", size = 656019, upload-time = "2024-12-05T15:17:10.37Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
]