From b6886cb34a34f6e5b03972309304da45255dfe3b Mon Sep 17 00:00:00 2001 From: phg Date: Sun, 8 Mar 2026 18:32:25 +0100 Subject: [PATCH] Initial working version --- .env.example | 14 + ARCHITECTURE.md | 5 +- README.md | 17 +- TASKS.md | 4 +- pyproject.toml | 40 + src/pve_vm_setup/__init__.py | 3 + src/pve_vm_setup/__main__.py | 4 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 232 bytes .../__pycache__/__main__.cpython-313.pyc | Bin 0 -> 309 bytes .../__pycache__/app.cpython-313.pyc | Bin 0 -> 3217 bytes .../__pycache__/cli.cpython-313.pyc | Bin 0 -> 1535 bytes .../__pycache__/doctor.cpython-313.pyc | Bin 0 -> 5707 bytes .../__pycache__/domain.cpython-313.pyc | Bin 0 -> 20844 bytes .../__pycache__/errors.cpython-313.pyc | Bin 0 -> 3206 bytes .../__pycache__/settings.cpython-313.pyc | Bin 0 -> 9228 bytes .../terminal_compat.cpython-313.pyc | Bin 0 -> 9117 bytes src/pve_vm_setup/app.py | 46 + src/pve_vm_setup/cli.py | 33 + src/pve_vm_setup/doctor.py | 80 ++ src/pve_vm_setup/domain.py | 320 +++++ src/pve_vm_setup/errors.py | 48 + src/pve_vm_setup/models/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 219 bytes .../__pycache__/workflow.cpython-313.pyc | Bin 0 -> 8004 bytes src/pve_vm_setup/models/workflow.py | 157 +++ src/pve_vm_setup/screens/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 217 bytes .../screens/__pycache__/login.cpython-313.pyc | Bin 0 -> 9831 bytes .../__pycache__/wizard.cpython-313.pyc | Bin 0 -> 65892 bytes src/pve_vm_setup/screens/login.py | 159 +++ src/pve_vm_setup/screens/wizard.py | 1183 +++++++++++++++++ src/pve_vm_setup/services/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 235 bytes .../services/__pycache__/base.cpython-313.pyc | Bin 0 -> 5201 bytes .../__pycache__/factory.cpython-313.pyc | Bin 0 -> 1020 bytes .../services/__pycache__/fake.cpython-313.pyc | Bin 0 -> 4767 bytes .../__pycache__/proxmox.cpython-313.pyc | Bin 0 -> 22065 bytes src/pve_vm_setup/services/base.py | 90 ++ src/pve_vm_setup/services/factory.py | 12 + src/pve_vm_setup/services/fake.py | 82 ++ src/pve_vm_setup/services/proxmox.py | 399 ++++++ src/pve_vm_setup/settings.py | 179 +++ src/pve_vm_setup/terminal_compat.py | 140 ++ src/pve_vm_setup/widgets/__init__.py | 1 + .../conftest.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 1132 bytes .../test_app.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 92526 bytes .../test_doctor.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 10263 bytes .../test_domain.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 9131 bytes .../test_factory.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 3385 bytes ...roxmox_client.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 16641 bytes ...test_settings.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 11190 bytes tests/conftest.py | 16 + ..._live_proxmox.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 9560 bytes tests/integration/test_live_proxmox.py | 59 + tests/test_app.py | 590 ++++++++ tests/test_doctor.py | 94 ++ tests/test_domain.py | 107 ++ tests/test_factory.py | 30 + tests/test_proxmox_client.py | 193 +++ tests/test_settings.py | 56 + uv.lock | 317 +++++ 61 files changed, 4475 insertions(+), 6 deletions(-) create mode 100644 .env.example create mode 100644 pyproject.toml create mode 100644 src/pve_vm_setup/__init__.py create mode 100644 src/pve_vm_setup/__main__.py create mode 100644 src/pve_vm_setup/__pycache__/__init__.cpython-313.pyc create mode 100644 src/pve_vm_setup/__pycache__/__main__.cpython-313.pyc create mode 100644 src/pve_vm_setup/__pycache__/app.cpython-313.pyc create mode 100644 src/pve_vm_setup/__pycache__/cli.cpython-313.pyc create mode 100644 src/pve_vm_setup/__pycache__/doctor.cpython-313.pyc create mode 100644 src/pve_vm_setup/__pycache__/domain.cpython-313.pyc create mode 100644 src/pve_vm_setup/__pycache__/errors.cpython-313.pyc create mode 100644 src/pve_vm_setup/__pycache__/settings.cpython-313.pyc create mode 100644 src/pve_vm_setup/__pycache__/terminal_compat.cpython-313.pyc create mode 100644 src/pve_vm_setup/app.py create mode 100644 src/pve_vm_setup/cli.py create mode 100644 src/pve_vm_setup/doctor.py create mode 100644 src/pve_vm_setup/domain.py create mode 100644 src/pve_vm_setup/errors.py create mode 100644 src/pve_vm_setup/models/__init__.py create mode 100644 src/pve_vm_setup/models/__pycache__/__init__.cpython-313.pyc create mode 100644 src/pve_vm_setup/models/__pycache__/workflow.cpython-313.pyc create mode 100644 src/pve_vm_setup/models/workflow.py create mode 100644 src/pve_vm_setup/screens/__init__.py create mode 100644 src/pve_vm_setup/screens/__pycache__/__init__.cpython-313.pyc create mode 100644 src/pve_vm_setup/screens/__pycache__/login.cpython-313.pyc create mode 100644 src/pve_vm_setup/screens/__pycache__/wizard.cpython-313.pyc create mode 100644 src/pve_vm_setup/screens/login.py create mode 100644 src/pve_vm_setup/screens/wizard.py create mode 100644 src/pve_vm_setup/services/__init__.py create mode 100644 src/pve_vm_setup/services/__pycache__/__init__.cpython-313.pyc create mode 100644 src/pve_vm_setup/services/__pycache__/base.cpython-313.pyc create mode 100644 src/pve_vm_setup/services/__pycache__/factory.cpython-313.pyc create mode 100644 src/pve_vm_setup/services/__pycache__/fake.cpython-313.pyc create mode 100644 src/pve_vm_setup/services/__pycache__/proxmox.cpython-313.pyc create mode 100644 src/pve_vm_setup/services/base.py create mode 100644 src/pve_vm_setup/services/factory.py create mode 100644 src/pve_vm_setup/services/fake.py create mode 100644 src/pve_vm_setup/services/proxmox.py create mode 100644 src/pve_vm_setup/settings.py create mode 100644 src/pve_vm_setup/terminal_compat.py create mode 100644 src/pve_vm_setup/widgets/__init__.py create mode 100644 tests/__pycache__/conftest.cpython-313-pytest-8.4.2.pyc create mode 100644 tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc create mode 100644 tests/__pycache__/test_doctor.cpython-313-pytest-8.4.2.pyc create mode 100644 tests/__pycache__/test_domain.cpython-313-pytest-8.4.2.pyc create mode 100644 tests/__pycache__/test_factory.cpython-313-pytest-8.4.2.pyc create mode 100644 tests/__pycache__/test_proxmox_client.cpython-313-pytest-8.4.2.pyc create mode 100644 tests/__pycache__/test_settings.cpython-313-pytest-8.4.2.pyc create mode 100644 tests/conftest.py create mode 100644 tests/integration/__pycache__/test_live_proxmox.cpython-313-pytest-8.4.2.pyc create mode 100644 tests/integration/test_live_proxmox.py create mode 100644 tests/test_app.py create mode 100644 tests/test_doctor.py create mode 100644 tests/test_domain.py create mode 100644 tests/test_factory.py create mode 100644 tests/test_proxmox_client.py create mode 100644 tests/test_settings.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f23e614 --- /dev/null +++ b/.env.example @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4bee49d..aefff38 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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? diff --git a/README.md b/README.md index 3a987f3..7218a67 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TASKS.md b/TASKS.md index 7b07c83..1c694f1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..485ff91 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/pve_vm_setup/__init__.py b/src/pve_vm_setup/__init__.py new file mode 100644 index 0000000..d4de65e --- /dev/null +++ b/src/pve_vm_setup/__init__.py @@ -0,0 +1,3 @@ +"""Proxmox VM setup TUI.""" + +__all__ = [] diff --git a/src/pve_vm_setup/__main__.py b/src/pve_vm_setup/__main__.py new file mode 100644 index 0000000..bfdcd0c --- /dev/null +++ b/src/pve_vm_setup/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/pve_vm_setup/__pycache__/__init__.cpython-313.pyc b/src/pve_vm_setup/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c42cf854d4babd46d31df075d5eed410c1023fe GIT binary patch literal 232 zcmey&%ge>Uz`&rBvNltTfq~&Mhy%lnP{wB-1_p+y48aUV4EhX3jOmP;OjV)*MfnxE z`4tLbz6!;uC8Y%lA)%gnews|T*yH0<@{{A^L5#$locQ>a44**;-3rtXElw>e)-T9N z*Dud6$}TQQOitA=D9S8LEYVNTEYT}AEYwR$EdwhkC`;8X%hd%NtzTS}tY4X5S`?pH zP@o?lpP83g5+AQuPUz`!syYHg-J0|Ucj5C?`?pp4IEAQcS348e@v3|@>yj0y}fOucGfVV}4GZ;BQp>;!3d&M-%W`##Q%gz<^oxs<^(*sBi{cXt3iM$%>lIYq z;;_lhPbtkwwJYLgU|;~buvm$Kf#Cx)BO~Ko2GIx1+#OYynI&&>iA)Tc9Cn#Y<^qe% O2WAEqsUl7W1_l6Uf>6N# literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/__pycache__/app.cpython-313.pyc b/src/pve_vm_setup/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..59971834d78fa54cbad6937ef93d49baea41137d GIT binary patch literal 3217 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 literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/__pycache__/doctor.cpython-313.pyc b/src/pve_vm_setup/__pycache__/doctor.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..33a6363310a6a11afc758175cfb43fc2c830ddd4 GIT binary patch literal 5707 zcmey&%ge>Uz`(F(!P?9k9tMWTAPx*OLl}&oQy3T+rZNOG1T%Uwcrg|+DuCEb-b`N1 zMa&8e!OX!d-Yj0MMXX+IMQmU`t2etBM-c~@&F0PN#Z|;Q z7_wNhn2Na5IW@Uof~?eJyv3cEmzQ6XSdy8aSFFi+i!CIzqQukxB_jg^gC^50o`9nK ziroAP*P^2QqFcPdsU;m0z)WEkR(JWG6_o~d8{~608c0@hI_E56*ibu;F&G z;joJ@l#NKc_)+YVg_w^_!tG+mVV6L#V30hr4hTC~NQxnpU6=u;A7&4c?h=Nn2Zy&J z#7tx|lp#n7nGa#xGHO5=3@JLfqM;l? zYDiW=nHc`Yo-eSZVwjKI858F);7XrjDB-0EwG@eh#e*&gP zBt=kWCX=rK0|PgM0z)2G9qRlGB6~nFfc@eN@K80usDwVibcN-6CXpc z1fo1lWyBH2lEj-yWVq{r9g9N1!acE{0hH$rQ6#|}q%h@13e(*|Mqp_a0x3*+^0;xh zAB!tOd9Z}36!H3r4pV93&BO>(?B$pY4iimK0tU<>(nMJtCYpf_K_SphjJAbIhq46A z2Q$F)F>kazTn1k9!sDKufgzMv30{iftlt$7`7d2jks+E5W+ppBqC5jb9y5-V%oxgy z?oxS%V1_(qN9J^92%W~5#$d}V&ydDg%%s3j%#_EO#sFn;r!gopL|ee@Lf9H@1rsx2 zW@kur!Q%os+%6z9%s9})EFFtq6d9;xdKv>Pd^DB(szlrzJ$)3C^YhX&(@TrM4N@zG zTZ~*qpmtpqdq`qYdTI$*6^DXCMt*ULl|mJdf`doXQB==!X6S%=bo z7PBxgFf@rvGFFM3=qWgsl%(bsfO;PaIr-_Cd7#j{#ZV;<3BSbBl8n^6lFa19lGK#= z#A4ej4O2aZu*96qlti!|P%JBeVp#!_1Qd#s^9xczhE$0ie42r#43=iEvBY|{QMjRm>Lkrwu)UrL7^lu-L{HfK|ui&0r3Sz zscD%NwpFa)w0?^#ihx~sl~-(R+&rW_M|EY7xSz z1&PJQ<@rS^w>UuJpg_IF1vUc|EyYFr3=9mnI6<)(4-$YfLD2zK24WW9Vgpf`DYrPl zDoYa6ZwZ50Wx4SnBcbtB1gZ;eafK!3l%|4vytmlXQ%e#{N{ViABq!$NfQk=IzFQo{ z&|dK^w&Iea)WqB(P=E0jb81EMEp}+f_m)6OQDR`TKTP$Fwvx3OtTPz@_-eLuj#YJKa3=GAf)~*5yC<0}mTl{Fbt;m6afdS-{ z;$RjAh6aWYJglsOH#h{Yb7)`W(7wW<3uen*p9xY| zSY&RnaDmwtGd!-#7+;hzz94O~!Rdma`4tumn4HZ5j_V4R7Zofo$Xo4Dx*%wKg~bjg zXE`I{x~%ap>b82aoMXk1say{Ke+LD6nU(gi`sD=bb>eHVmP7i3&lv$?2db6w5* zqMG*wRi6`17Xjk!|u9<|3wY|3+e$E0)j3G247(bdB7)ooloT= zpUQ%W%Y1qb?jKlmSVeAd@b&X_@>~$Ly24?7Lt5#&wCP1@)63H47dRxKaudof$mv~> z)n8F`LDc99hw%fL7Sk&nW;cW+uM25h6w`kqzj^kS2&E|>UFPh z=-m*MxGt!2QBdWwpjrnTOnvQw;N=kuBQAoAU;he+ z!3`nF>ADkjZ}5m*=TW@Kqj;T1eL)bIr+Se`^*WFC3L!90=^~HPbsmicNuSwSgt0xFEE)NIVJLKQ#DzXEFbN8M zU}NAF23a)2dO^i?b=!;Tw%66&FRHs=Q1dv!bV1Vl3Xc!6>DT%7Zx~u!H*~&e=zPV{ z^|FN9MMKvWBJ-u^NMAH`oxplQ!tI8z^mSp)i^7`Mg^e!?8}Bf^EbMScO8thE{sxZg z25uJ(+^!o0Tr>!{s2_YGIO&2!@@HlyX|WFsOrl~R*cb%Gr}IqYnNhvM;<~ouMQz9H z+TItny)S5ilA5gl6@dU~z|1gTU~yg9@}jckb!DfE%1#%QoDUdXkaWAk;|^0{k1!+j zqIT#7t*{GW5f>yQukb{D;bIV!n8Ch4e1*zo0sRivJKVw(T>I@i?Qil3_P9TQWUvc@ zMpsyjZ*cSc_)^Wtz$5vYfeDmRAqiGodxi4~(+x})M2)X-nB3tP`2B&EiB<5=Coe_@ zQAfs~pZFLg-5Gy;5n$kzoTIx&>H@b3vi=FBGlFNhE)bd!e}zN!How3V0dXA8>2?3W z%)lf4`vW%O+|zGm@jdO26HlBl4l7PWVp=9 z8qCLhnU5L77E}+mVD{7GC<667ia-N+MWC)*5vXMVE@i8vODYReqp=P{#)3-LlG38Q zTg;hxC7L|9xZ>l}N=r(MQsd)`K!X9d*h(r3GV{{G#@%8|1&whQgA08~wJA^x8MQ3d zgAQ93aWF72Ah`upz^-KY3~GfzieL_#-29Z%oK(9aPX-1C(9mMB7Xt&s2WCb_#=8tk zPZ=biGw9!CFuu#6_<@hX#FOzOryC<9?+rf556mD2IG2222QxVs#0)=xr9W^nuyKCl M28kAVFfcFx0R0A literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/__pycache__/domain.cpython-313.pyc b/src/pve_vm_setup/__pycache__/domain.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc4f1a35154981c1bbef87452edc3229d475d0f1 GIT binary patch literal 20844 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 z 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 literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/__pycache__/settings.cpython-313.pyc b/src/pve_vm_setup/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eac1a8c959bc11a682b91f87aad4f89f2a5a98e5 GIT binary patch literal 9228 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 literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/__pycache__/terminal_compat.cpython-313.pyc b/src/pve_vm_setup/__pycache__/terminal_compat.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0fd42cecec462d5f2d5812d387ae3a1b9ed2840 GIT binary patch literal 9117 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 Cqi>l2 literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/app.py b/src/pve_vm_setup/app.py new file mode 100644 index 0000000..1e22cb9 --- /dev/null +++ b/src/pve_vm_setup/app.py @@ -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() diff --git a/src/pve_vm_setup/cli.py b/src/pve_vm_setup/cli.py new file mode 100644 index 0000000..2a25304 --- /dev/null +++ b/src/pve_vm_setup/cli.py @@ -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 diff --git a/src/pve_vm_setup/doctor.py b/src/pve_vm_setup/doctor.py new file mode 100644 index 0000000..6584fba --- /dev/null +++ b/src/pve_vm_setup/doctor.py @@ -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 diff --git a/src/pve_vm_setup/domain.py b/src/pve_vm_setup/domain.py new file mode 100644 index 0000000..507f4a7 --- /dev/null +++ b/src/pve_vm_setup/domain.py @@ -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\d{2})[.-](?P\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) diff --git a/src/pve_vm_setup/errors.py b/src/pve_vm_setup/errors.py new file mode 100644 index 0000000..023a305 --- /dev/null +++ b/src/pve_vm_setup/errors.py @@ -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 diff --git a/src/pve_vm_setup/models/__init__.py b/src/pve_vm_setup/models/__init__.py new file mode 100644 index 0000000..5c7ad5c --- /dev/null +++ b/src/pve_vm_setup/models/__init__.py @@ -0,0 +1 @@ +"""Application models.""" diff --git a/src/pve_vm_setup/models/__pycache__/__init__.cpython-313.pyc b/src/pve_vm_setup/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a1359b6c0ba9fd7007a7026a1f8866bb4fca60a4 GIT binary patch literal 219 zcmey&%ge>Uz`(F9YHg+z0|Ucj5C?`Cp^VQQ3=9lY8G;##7}6OvnW}^x3kq^FlM_oa z^YavP^HWlDiuL?78E>)2$EV~c$H%W^_zco{D?&fCIJKx)zaS%BzdXMvySN}RIaR-) zD6=fFL_a;VM6cMeP%kC546LA_ELFEGSGPE|q_jZ4xF}h_GQYGaKCz%cA7X=ke0*kJ tW=VX!UP0w84x8Nkl+v73yCOCQ1_qEji$QMuz|6?Vc#A=@h=qZH0RRr7J{|x7 literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/models/__pycache__/workflow.cpython-313.pyc b/src/pve_vm_setup/models/__pycache__/workflow.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78df45906ac95e86a52d6a21cecc7d906308dc21 GIT binary patch literal 8004 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 literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/models/workflow.py b/src/pve_vm_setup/models/workflow.py new file mode 100644 index 0000000..afee98c --- /dev/null +++ b/src/pve_vm_setup/models/workflow.py @@ -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() diff --git a/src/pve_vm_setup/screens/__init__.py b/src/pve_vm_setup/screens/__init__.py new file mode 100644 index 0000000..2083d4f --- /dev/null +++ b/src/pve_vm_setup/screens/__init__.py @@ -0,0 +1 @@ +"""Textual screens.""" diff --git a/src/pve_vm_setup/screens/__pycache__/__init__.cpython-313.pyc b/src/pve_vm_setup/screens/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3db392402347435604b016bd12f955b5677510e0 GIT binary patch literal 217 zcmey&%ge>Uz`(F9YHg-C0|Ucj5C?`Cp^VQQ3=9lY8G;##7}6OvnW_XrQY%VI6LS=b zlZ#SQ^NRKSG#PKP$H%ASC&$OHWcUoyb}Ldpv^ce>Sic}6UB5iPD7&~IF*#MgpeVB} zu|z*TvqZ1huuv~0wG6DFpe$9lELXQUwWPE_zqlw_zcRnHC_b^EKp$#_etdjpUS>&r qyk0@&Ee@O9{FKt1RJ$TJ1_lO@D~mxc{J_k}$asrEtcZnyfdK%6Lp^5z literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/screens/__pycache__/login.cpython-313.pyc b/src/pve_vm_setup/screens/__pycache__/login.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8036d3f4b83fc7f42016030941e66294494f78bc GIT binary patch literal 9831 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 literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/screens/login.py b/src/pve_vm_setup/screens/login.py new file mode 100644 index 0000000..65bb9ea --- /dev/null +++ b/src/pve_vm_setup/screens/login.py @@ -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)) diff --git a/src/pve_vm_setup/screens/wizard.py b/src/pve_vm_setup/screens/wizard.py new file mode 100644 index 0000000..356e8bb --- /dev/null +++ b/src/pve_vm_setup/screens/wizard.py @@ -0,0 +1,1183 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor + +from textual import on +from textual.app import ComposeResult +from textual.containers import Horizontal, HorizontalGroup, ScrollableContainer, Vertical +from textual.message import Message +from textual.screen import ModalScreen +from textual.widget import Widget +from textual.widgets import Button, Checkbox, Input, Select, Static + +from ..domain import ( + build_confirmation_text, + select_latest_nixos_iso, + validate_all_steps, + validate_step, +) +from ..errors import ProxmoxError, ProxmoxPostCreateError +from ..models.workflow import WIZARD_STEPS, DiskConfig, ReferenceData, WorkflowState +from ..services.base import Bridge, ProxmoxService, Storage +from ..settings import AppSettings + +GUEST_VERSIONS: dict[str, list[tuple[str, str]]] = { + "linux": [("6.x - 2.6 Kernel", "l26"), ("2.4 Kernel", "l24"), ("Other Linux", "other")], + "windows": [ + ("Windows 11/2022+", "win11"), + ("Windows 10/2019", "win10"), + ("Windows 8/2012", "win8"), + ], + "solaris": [("Solaris", "solaris")], + "other": [("Other", "other")], +} +NO_DISK_SELECTED = "__no_disk__" + + +class AutoStartConfirmModal(ModalScreen[bool | None]): + DEFAULT_CSS = """ + AutoStartConfirmModal { + align: center middle; + } + + #auto-start-dialog { + width: 50; + height: auto; + border: round $accent; + background: $surface; + padding: 1 2; + } + + #auto-start-title { + text-style: bold; + } + + #auto-start-actions { + margin-top: 1; + height: auto; + } + + #auto-start-actions Button { + min-width: 8; + margin-right: 1; + } + """ + + BINDINGS = [("escape", "cancel", "Cancel")] + + def compose(self) -> ComposeResult: + with Vertical(id="auto-start-dialog"): + yield Static("Start VM Automatically?", id="auto-start-title") + yield Static("Should the VM be started automatically after creation?") + with HorizontalGroup(id="auto-start-actions"): + yield Button("Yes", id="auto-start-yes", variant="success") + yield Button("No", id="auto-start-no") + + def on_mount(self) -> None: + self.set_focus(self.query_one("#auto-start-yes", Button)) + + def action_cancel(self) -> None: + self.dismiss(None) + + @on(Button.Pressed, "#auto-start-yes") + def on_yes_pressed(self) -> None: + self.dismiss(True) + + @on(Button.Pressed, "#auto-start-no") + def on_no_pressed(self) -> None: + self.dismiss(False) + + +class WizardView(Vertical): + can_focus = False + + class SubmitFinished(Message): + def __init__(self, success: bool, message: str) -> None: + self.success = success + self.message = message + super().__init__() + + DEFAULT_CSS = """ + WizardView { + display: none; + height: 1fr; + layout: vertical; + padding: 1 2; + } + + .section { + border: round $accent; + height: 1fr; + layout: vertical; + padding: 1 2; + margin-top: 1; + } + + .field { + margin-top: 1; + } + + WizardView Input { + height: 1; + min-height: 1; + border: none; + padding: 0 1; + } + + WizardView Input:focus { + height: 1; + min-height: 1; + border: none; + background-tint: $foreground 5%; + } + + WizardView Select { + height: 1; + min-height: 1; + } + + WizardView Select > SelectCurrent { + height: 1; + min-height: 1; + border: none; + padding: 0 1; + } + + WizardView Select:focus > SelectCurrent { + border: none; + background-tint: $foreground 5%; + } + + WizardView Button { + height: 1; + min-height: 1; + width: auto; + min-width: 10; + padding: 0 1; + border: none; + color: $foreground; + } + + WizardView Button:focus, + WizardView Button:hover, + WizardView Button.-active { + height: 1; + min-height: 1; + border: none; + background-tint: $foreground 8%; + } + + WizardView Button.-primary, + WizardView Button.-primary:focus, + WizardView Button.-primary:hover, + WizardView Button.-primary.-active, + WizardView Button.-success, + WizardView Button.-success:focus, + WizardView Button.-success:hover, + WizardView Button.-success.-active { + color: $button-color-foreground; + border: none; + } + + WizardView Checkbox { + height: 1; + min-height: 1; + border: none; + padding: 0 1; + } + + WizardView Checkbox:focus { + height: 1; + min-height: 1; + border: none; + background-tint: $foreground 5%; + } + + #general-tag-add, + #general-tag-use, + #general-tag-remove { + width: auto; + margin-top: 1; + } + + #disks-add, + #disks-remove { + width: auto; + margin-right: 1; + } + + #disks-select { + width: 1fr; + } + + #wizard-actions { + margin-top: 1; + height: auto; + } + + #wizard-errors { + color: $error; + margin-top: 1; + } + + #wizard-status { + color: $text-muted; + margin-top: 1; + } + + #wizard-title { + text-style: bold; + } + """ + + def __init__( + self, settings: AppSettings, workflow: WorkflowState, service: ProxmoxService + ) -> None: + super().__init__() + self._settings = settings + self._workflow = workflow + self._service = service + self._selected_disk_index = 0 + self._node_load_in_flight = False + self._suppress_node_change = False + self._suppress_storage_change = False + self._suppress_disk_selection_change = False + self._initializing_reference_data = False + self._loaded_node_reference: str | None = None + self._loaded_iso_source: tuple[str, str] | None = None + + def _section(self, section_id: str, title: str) -> ScrollableContainer: + section = ScrollableContainer(id=section_id, classes="section") + section.border_title = f" {title} " + return section + + def compose(self): + yield Static("Wizard", id="wizard-title") + yield Static("", id="wizard-step") + yield Static("", id="wizard-errors") + yield Static("", id="wizard-status") + + with self._section("general-section", "General"): + yield Select[str]( + [], prompt="Node", id="general-node", allow_blank=True, classes="field" + ) + yield Input(id="general-vmid", placeholder="VM ID", classes="field") + yield Input(id="general-name", placeholder="VM Name", classes="field") + yield Select[str]( + [], + prompt="Ressource Pool", + id="general-pool", + allow_blank=True, + classes="field", + ) + yield Input(id="general-tag-input", placeholder="Add tag", classes="field") + yield Button("Add Tag", id="general-tag-add") + yield Select[str]( + [], + prompt="Existing tags", + id="general-tag-existing", + allow_blank=True, + classes="field", + ) + yield Button("Use Existing", id="general-tag-use") + yield Select[str]( + [], + prompt="Current tags", + id="general-tag-current", + allow_blank=True, + classes="field", + ) + yield Button("Remove Tag", id="general-tag-remove") + yield Checkbox("High availability", value=True, id="general-ha", classes="field") + yield Checkbox("Start at boot", value=False, id="general-onboot", classes="field") + yield Input(id="general-startup-order", placeholder="Startup order", classes="field") + yield Input( + id="general-startup-delay", placeholder="Startup delay (seconds)", classes="field" + ) + yield Input( + id="general-shutdown-timeout", + placeholder="Shutdown timeout (seconds)", + classes="field", + ) + + with self._section("os-section", "Operating System"): + yield Select[str]( + [("ISO", "iso"), ("Physical disc drive", "physical"), ("No media", "none")], + value="iso", + id="os-media-choice", + allow_blank=False, + classes="field", + ) + yield Select[str]( + [], prompt="ISO storage", id="os-storage", allow_blank=True, classes="field" + ) + yield Select[str]( + [], prompt="ISO image", id="os-iso", allow_blank=True, classes="field" + ) + yield Input( + id="os-physical-drive", + placeholder="Physical drive path", + value="/dev/sr0", + classes="field", + ) + yield Select[str]( + [ + ("Linux", "linux"), + ("Windows", "windows"), + ("Solaris", "solaris"), + ("Other", "other"), + ], + value="linux", + id="os-guest-type", + allow_blank=False, + classes="field", + ) + yield Select[str]( + GUEST_VERSIONS["linux"], + value="l26", + prompt="Guest version", + id="os-guest-version", + allow_blank=False, + classes="field", + ) + + with self._section("system-section", "System"): + yield Select[str]( + [ + ("Default", "default"), + ("Standard", "std"), + ("VirtIO GPU", "virtio"), + ("VMware", "vmware"), + ("QXL", "qxl"), + ], + value="default", + id="system-graphic-card", + allow_blank=False, + classes="field", + ) + yield Select[str]( + [("q35", "q35"), ("i440fx", "pc")], + value="q35", + id="system-machine", + allow_blank=False, + classes="field", + ) + yield Select[str]( + [("OVMF (UEFI)", "ovmf"), ("SeaBIOS", "seabios")], + value="ovmf", + id="system-bios", + allow_blank=False, + classes="field", + ) + yield Checkbox("Add EFI disk", value=True, id="system-add-efi", classes="field") + yield Select[str]( + [], prompt="EFI storage", id="system-efi-storage", allow_blank=True, classes="field" + ) + yield Checkbox("Pre-enroll keys", value=False, id="system-pre-enroll", classes="field") + yield Select[str]( + [ + ("VirtIO SCSI single", "virtio-scsi-single"), + ("VirtIO SCSI", "virtio-scsi-pci"), + ("LSI", "lsi"), + ("MegaSAS", "megasas"), + ("PVSCSI", "pvscsi"), + ], + value="virtio-scsi-single", + id="system-scsi-controller", + allow_blank=False, + classes="field", + ) + yield Checkbox("Qemu agent", value=True, id="system-qemu-agent", classes="field") + yield Checkbox("Add TPM", value=False, id="system-tpm", classes="field") + + with self._section("disks-section", "Disks"): + with HorizontalGroup(classes="field"): + yield Button("Add Disk", id="disks-add") + yield Button("Remove Disk", id="disks-remove") + with HorizontalGroup(classes="field"): + yield Select[str]( + [("No disks configured", NO_DISK_SELECTED)], + value=NO_DISK_SELECTED, + prompt="Disk", + id="disks-select", + allow_blank=False, + ) + yield Static("", id="disks-summary", classes="field") + yield Select[str]( + [("SCSI", "scsi"), ("VirtIO", "virtio"), ("SATA", "sata"), ("IDE", "ide")], + value="scsi", + id="disk-bus", + allow_blank=False, + classes="field", + ) + yield Input(id="disk-device", placeholder="Device index", classes="field") + yield Select[str]( + [], prompt="Disk storage", id="disk-storage", allow_blank=True, classes="field" + ) + yield Input(id="disk-size", placeholder="Disk size GiB", classes="field") + yield Static("Format: raw", id="disk-format", classes="field") + yield Select[str]( + [ + ("No cache", "none"), + ("Write back", "writeback"), + ("Write through", "writethrough"), + ("Direct sync", "directsync"), + ("Unsafe", "unsafe"), + ], + value="none", + id="disk-cache", + allow_blank=False, + classes="field", + ) + yield Checkbox("Discard", value=False, id="disk-discard", classes="field") + yield Checkbox("IO thread", value=True, id="disk-io-thread", classes="field") + yield Checkbox("SSD emulation", value=True, id="disk-ssd", classes="field") + yield Checkbox("Include in backup", value=True, id="disk-backup", classes="field") + yield Checkbox( + "Skip replication", value=False, id="disk-skip-replication", classes="field" + ) + yield Select[str]( + [("io_uring", "io_uring"), ("native", "native"), ("threads", "threads")], + value="io_uring", + id="disk-aio", + allow_blank=False, + classes="field", + ) + + with self._section("cpu-section", "CPU"): + yield Input(id="cpu-cores", value="2", placeholder="Cores", classes="field") + yield Input(id="cpu-sockets", value="1", placeholder="Sockets", classes="field") + yield Select[str]( + [("host", "host"), ("kvm64", "kvm64"), ("x86-64-v2-AES", "x86-64-v2-AES")], + value="host", + id="cpu-type", + allow_blank=False, + classes="field", + ) + + with self._section("memory-section", "Memory"): + yield Input(id="memory-size", value="2048", placeholder="Memory MiB", classes="field") + yield Input( + id="memory-min-size", value="2048", placeholder="Min memory MiB", classes="field" + ) + yield Checkbox("Ballooning", value=True, id="memory-ballooning", classes="field") + yield Checkbox("Allow KSM", value=True, id="memory-ksm", classes="field") + + with self._section("network-section", "Network"): + yield Checkbox("No network device", value=False, id="network-none", classes="field") + yield Select[str]( + [], prompt="Bridge", id="network-bridge", allow_blank=True, classes="field" + ) + yield Input(id="network-vlan", placeholder="VLAN tag", classes="field") + yield Select[str]( + [ + ("VirtIO", "virtio"), + ("E1000", "e1000"), + ("VMXNET3", "vmxnet3"), + ("RTL8139", "rtl8139"), + ], + value="virtio", + id="network-model", + allow_blank=False, + classes="field", + ) + yield Input(id="network-mac", placeholder="MAC address (blank = auto)", classes="field") + yield Checkbox("Firewall", value=True, id="network-firewall", classes="field") + yield Checkbox("Disconnected", value=False, id="network-disconnected", classes="field") + yield Input(id="network-mtu", placeholder="MTU", classes="field") + yield Input(id="network-rate", placeholder="Rate limit MB/s", classes="field") + yield Input(id="network-queues", placeholder="Multiqueue", classes="field") + + with self._section("confirm-section", "Confirm"): + yield Static("", id="confirm-summary", classes="field") + yield Static("", id="confirm-result", classes="field") + + with Horizontal(id="wizard-actions"): + yield Button("Next", id="wizard-next", variant="primary") + yield Button("Back", id="wizard-back") + yield Button("Create VM", id="wizard-create", variant="success") + + def on_mount(self) -> None: + self._set_guest_version_options("linux") + self._refresh_disk_options() + self._load_selected_disk_into_widgets() + self._show_step() + self._sync_media_visibility() + self._sync_system_visibility() + self._sync_memory_visibility() + self._sync_network_visibility() + + def activate(self) -> None: + self.styles.display = "block" + self._show_status("Loading live reference data...") + self._initializing_reference_data = True + self.call_after_refresh(self._focus_current_step, True) + self.run_worker(self._load_initial_data, thread=True, exclusive=True) + + def _load_initial_data(self) -> None: + with ThreadPoolExecutor(max_workers=4) as executor: + nodes_future = executor.submit(self._service.load_nodes) + pools_future = executor.submit(self._service.load_pools) + tags_future = executor.submit(self._service.load_existing_tags) + next_vmid_future = executor.submit(self._service.load_next_vmid) + + nodes = nodes_future.result() + pools = pools_future.result() + tags = tags_future.result() + next_vmid = next_vmid_future.result() + node_names = [node.name for node in nodes] + preferred_node = ( + self._settings.safety_policy.test_node + if self._settings.safety_policy.enable_test_mode + else "" + ) or ( + "sbx0pve00" if "sbx0pve00" in node_names else (node_names[0] if node_names else "") + ) + references = ReferenceData( + nodes=node_names, + pools=[pool.poolid for pool in pools], + existing_tags=tags, + ) + storages: list[Storage] = [] + bridges: list[Bridge] = [] + isos: list[str] = [] + if preferred_node: + with ThreadPoolExecutor(max_workers=2) as executor: + storages_future = executor.submit(self._service.load_storages, preferred_node) + bridges_future = executor.submit(self._service.load_bridges, preferred_node) + storages = storages_future.result() + bridges = bridges_future.result() + iso_storages = sorted( + storage.storage for storage in storages if "iso" in storage.content + ) + default_iso_storage = ( + "cephfs" if "cephfs" in iso_storages else (iso_storages[0] if iso_storages else "") + ) + if default_iso_storage: + isos = [ + iso.volid + for iso in self._service.load_isos(preferred_node, default_iso_storage) + ] + self.app.call_from_thread( + self._apply_initial_reference_data, + references, + next_vmid, + preferred_node, + storages, + bridges, + isos, + ) + + def _load_node_dependent_data(self, node: str) -> None: + with ThreadPoolExecutor(max_workers=2) as executor: + storages_future = executor.submit(self._service.load_storages, node) + bridges_future = executor.submit(self._service.load_bridges, node) + storages = storages_future.result() + bridges = bridges_future.result() + self.app.call_from_thread(self._apply_node_reference_data, node, storages, bridges) + + def _apply_initial_reference_data( + self, + references: ReferenceData, + next_vmid: int, + preferred_node: str, + storages: list[Storage], + bridges: list[Bridge], + isos: list[str], + ) -> None: + self._workflow.reference_data.nodes = references.nodes + self._workflow.reference_data.pools = references.pools + self._workflow.reference_data.existing_tags = references.existing_tags + + self._set_select_options("general-node", references.nodes) + self._set_select_options("general-pool", references.pools) + self._set_select_options("general-tag-existing", references.existing_tags) + + if preferred_node: + self._suppress_node_change = True + self.query_one("#general-node", Select).value = preferred_node + self._suppress_node_change = False + self._workflow.config.general.node = preferred_node + self.query_one("#general-vmid", Input).value = str(next_vmid) + self._workflow.config.general.vmid = next_vmid + if self._settings.safety_policy.enable_test_mode and self._settings.safety_policy.test_pool: + self.query_one("#general-pool", Select).value = self._settings.safety_policy.test_pool + self._workflow.config.general.pool = self._settings.safety_policy.test_pool + if preferred_node: + self._apply_node_reference_data(preferred_node, storages, bridges) + if isos: + storage = self._select("os-storage") + self._apply_isos(preferred_node, storage, isos) + self._initializing_reference_data = False + self._show_status("Loaded general reference data.") + self._focus_current_step(force=True) + + def _apply_node_reference_data( + self, + node: str, + storages: list[Storage], + bridges: list[Bridge], + ) -> None: + all_storages = sorted(storage.storage for storage in storages) + iso_storages = sorted(storage.storage for storage in storages if "iso" in storage.content) + disk_storages = sorted( + storage.storage for storage in storages if "images" in storage.content + ) + bridge_names = [bridge.iface for bridge in bridges] + + refs = self._workflow.reference_data + refs.all_storages = all_storages + refs.iso_storages = iso_storages + refs.disk_storages = disk_storages + refs.bridges = bridge_names + self._loaded_node_reference = node + + self._set_select_options("os-storage", iso_storages) + self._set_select_options("system-efi-storage", disk_storages) + self._set_select_options("disk-storage", disk_storages) + self._set_select_options("network-bridge", bridge_names) + + default_iso_storage = ( + "cephfs" if "cephfs" in iso_storages else (iso_storages[0] if iso_storages else "") + ) + default_disk_storage = ( + "ceph-pool" + if "ceph-pool" in disk_storages + else (disk_storages[0] if disk_storages else "") + ) + default_bridge = ( + "vmbr9" if "vmbr9" in bridge_names else (bridge_names[0] if bridge_names else "") + ) + + if default_iso_storage: + self._suppress_storage_change = True + self.query_one("#os-storage", Select).value = default_iso_storage + self._suppress_storage_change = False + self._workflow.config.os.storage = default_iso_storage + if default_disk_storage: + self.query_one("#system-efi-storage", Select).value = default_disk_storage + self.query_one("#disk-storage", Select).value = default_disk_storage + self._workflow.config.system.efi_storage = default_disk_storage + if self._workflow.config.disks: + self._workflow.config.disks[0].storage = default_disk_storage + self._load_selected_disk_into_widgets() + if default_bridge: + self.query_one("#network-bridge", Select).value = default_bridge + self._workflow.config.network.bridge = default_bridge + self._node_load_in_flight = False + self._show_status(f"Loaded node-specific reference data for {node}.") + self._focus_current_step(force=True) + + def _load_isos(self, node: str, storage: str) -> None: + isos = self._service.load_isos(node, storage) + self.app.call_from_thread(self._apply_isos, node, storage, [iso.volid for iso in isos]) + + def _apply_isos(self, node: str, storage: str, iso_values: list[str]) -> None: + self._workflow.reference_data.isos = iso_values + self._loaded_iso_source = (node, storage) + self._set_select_options("os-iso", iso_values) + preferred = select_latest_nixos_iso(iso_values) or (iso_values[0] if iso_values else "") + if preferred: + self.query_one("#os-iso", Select).value = preferred + self._workflow.config.os.iso = preferred + self._show_status(f"Loaded {len(iso_values)} ISO image(s) from {storage}.") + self._focus_current_step(force=True) + + def _show_step(self) -> None: + for step in WIZARD_STEPS: + section = self.query_one(f"#{step}-section", ScrollableContainer) + is_current = step == self._workflow.current_step + section.display = is_current + if is_current: + section.scroll_to(y=0, animate=False, immediate=True, force=True) + self.query_one("#wizard-title", Static).update("Create Proxmox VM") + step_label = ( + f"Step {self._workflow.current_step_index + 1}/" + f"{len(WIZARD_STEPS)}: {self._workflow.step_title}" + ) + self.query_one("#wizard-step", Static).update(step_label) + self.query_one("#wizard-back", Button).disabled = self._workflow.current_step_index == 0 + is_confirm = self._workflow.current_step == "confirm" + self.query_one("#wizard-next", Button).styles.display = "none" if is_confirm else "block" + self.query_one("#wizard-create", Button).styles.display = "block" if is_confirm else "none" + if is_confirm: + self._update_confirmation() + self._update_confirm_actions() + self.call_after_refresh(self._focus_current_step, True) + + def _show_errors(self, errors: list[str]) -> None: + self.query_one("#wizard-errors", Static).update("\n".join(errors)) + + def _show_status(self, message: str) -> None: + self.query_one("#wizard-status", Static).update(message) + + def _focus_current_step(self, force: bool = False) -> None: + focused = self.app.focused + if not force and focused is not None and focused is not self: + return + + target_ids = { + "general": ["general-name", "general-vmid", "general-node", "wizard-next"], + "os": ["os-media-choice", "os-storage", "os-iso", "os-physical-drive", "wizard-next"], + "system": ["system-graphic-card", "system-machine", "wizard-next"], + "disks": ["disks-select", "disk-size", "wizard-next"], + "cpu": ["cpu-cores", "cpu-sockets", "wizard-next"], + "memory": ["memory-size", "memory-min-size", "wizard-next"], + "network": ["network-bridge", "network-model", "wizard-next"], + "confirm": ["wizard-create", "wizard-back"], + }[self._workflow.current_step] + + for target_id in target_ids: + matches = self.query(f"#{target_id}") + if not matches: + continue + widget = matches.first() + if not widget.display or widget.disabled: + continue + self.app.set_focus(widget) + return + + def _set_widget_visibility(self, widget_id: str, visible: bool) -> None: + widget = self.query_one(f"#{widget_id}", Widget) + if not visible and isinstance(widget, Select): + widget.expanded = False + widget.display = visible + widget.disabled = not visible + + def _set_select_options(self, widget_id: str, values: list[str]) -> None: + widget = self.query_one(f"#{widget_id}", Select) + widget.set_options([(value, value) for value in values]) + + def _input(self, widget_id: str) -> str: + return self.query_one(f"#{widget_id}", Input).value.strip() + + def _select(self, widget_id: str) -> str: + value = self.query_one(f"#{widget_id}", Select).value + return value if isinstance(value, str) else "" + + def _checked(self, widget_id: str) -> bool: + return self.query_one(f"#{widget_id}", Checkbox).value + + def _sync_all_from_widgets(self) -> None: + config = self._workflow.config + config.general.node = self._select("general-node") + config.general.vmid = int(self._input("general-vmid") or "0") + config.general.name = self._input("general-name") + config.general.pool = self._select("general-pool") + config.general.ha_enabled = self._checked("general-ha") + config.general.onboot = self._checked("general-onboot") + config.general.startup_order = self._input("general-startup-order") + config.general.startup_delay = self._input("general-startup-delay") + config.general.shutdown_timeout = self._input("general-shutdown-timeout") + + config.os.media_choice = self._select("os-media-choice") or "iso" + config.os.storage = self._select("os-storage") + config.os.iso = self._select("os-iso") + config.os.physical_drive_path = self._input("os-physical-drive") or "/dev/sr0" + config.os.guest_type = self._select("os-guest-type") or "linux" + config.os.guest_version = self._select("os-guest-version") or "l26" + + config.system.graphic_card = self._select("system-graphic-card") or "default" + config.system.machine = self._select("system-machine") or "q35" + config.system.bios = self._select("system-bios") or "ovmf" + config.system.add_efi_disk = self._checked("system-add-efi") + config.system.efi_storage = self._select("system-efi-storage") + config.system.pre_enrolled_keys = self._checked("system-pre-enroll") + config.system.scsi_controller = ( + self._select("system-scsi-controller") or "virtio-scsi-single" + ) + config.system.qemu_agent = self._checked("system-qemu-agent") + config.system.tpm_enabled = self._checked("system-tpm") + + self._sync_selected_disk_from_widgets() + + config.cpu.cores = int(self._input("cpu-cores") or "0") + config.cpu.sockets = int(self._input("cpu-sockets") or "0") + config.cpu.cpu_type = self._select("cpu-type") or "host" + + config.memory.memory_mib = int(self._input("memory-size") or "0") + config.memory.min_memory_mib = int(self._input("memory-min-size") or "0") + config.memory.ballooning = self._checked("memory-ballooning") + config.memory.allow_ksm = self._checked("memory-ksm") + + config.network.no_network_device = self._checked("network-none") + config.network.bridge = self._select("network-bridge") + config.network.vlan_tag = self._input("network-vlan") + config.network.model = self._select("network-model") or "virtio" + config.network.mac_address = self._input("network-mac") + config.network.firewall = self._checked("network-firewall") + config.network.disconnected = self._checked("network-disconnected") + config.network.mtu = self._input("network-mtu") + config.network.rate_limit = self._input("network-rate") + config.network.multiqueue = self._input("network-queues") + + def _set_guest_version_options(self, guest_type: str) -> None: + versions = GUEST_VERSIONS.get(guest_type, GUEST_VERSIONS["other"]) + widget = self.query_one("#os-guest-version", Select) + widget.set_options(versions) + widget.value = versions[0][1] + + def _update_confirmation(self) -> None: + self._sync_all_from_widgets() + summary = build_confirmation_text(self._workflow.config, self._settings) + self.query_one("#confirm-summary", Static).update(summary) + result = self._workflow.submission.message + self.query_one("#confirm-result", Static).update(result) + + def _confirm_action_is_exit(self) -> bool: + return self._workflow.submission.phase in {"success", "partial"} + + def _update_confirm_actions(self) -> None: + button = self.query_one("#wizard-create", Button) + if self._confirm_action_is_exit(): + button.label = "Exit" + button.variant = "primary" + button.disabled = False + return + button.label = "Create VM" + button.variant = "success" + button.disabled = self._workflow.submission.phase == "running" + + def _sync_media_visibility(self) -> None: + choice = self._select("os-media-choice") or "iso" + self._set_widget_visibility("os-storage", choice == "iso") + self._set_widget_visibility("os-iso", choice == "iso") + self._set_widget_visibility("os-physical-drive", choice == "physical") + + def _sync_system_visibility(self) -> None: + show_efi_storage = self._checked("system-add-efi") or self._checked("system-tpm") + self._set_widget_visibility("system-efi-storage", show_efi_storage) + self._set_widget_visibility("system-pre-enroll", self._checked("system-add-efi")) + + def _sync_memory_visibility(self) -> None: + show_ballooning_fields = self._checked("memory-ballooning") + self._set_widget_visibility("memory-min-size", show_ballooning_fields) + self._set_widget_visibility("memory-ksm", show_ballooning_fields) + + def _sync_network_visibility(self) -> None: + visible = not self._checked("network-none") + for widget_id in [ + "network-bridge", + "network-vlan", + "network-model", + "network-mac", + "network-firewall", + "network-disconnected", + "network-mtu", + "network-rate", + "network-queues", + ]: + self._set_widget_visibility(widget_id, visible) + + def _selected_disk(self) -> DiskConfig | None: + if not self._workflow.config.disks: + return None + self._selected_disk_index = min( + self._selected_disk_index, len(self._workflow.config.disks) - 1 + ) + return self._workflow.config.disks[self._selected_disk_index] + + def _sync_selected_disk_from_widgets(self, *, refresh_options: bool = True) -> None: + disk = self._selected_disk() + if disk is None: + return + disk.bus = self._select("disk-bus") or "scsi" + disk.device = int(self._input("disk-device") or "0") + disk.storage = self._select("disk-storage") + disk.size_gib = int(self._input("disk-size") or "0") + disk.cache = self._select("disk-cache") or "none" + disk.discard = self._checked("disk-discard") + disk.io_thread = self._checked("disk-io-thread") + disk.ssd_emulation = self._checked("disk-ssd") + disk.backup = self._checked("disk-backup") + disk.skip_replication = self._checked("disk-skip-replication") + disk.async_io = self._select("disk-aio") or "io_uring" + if refresh_options: + self._refresh_disk_options() + + def _load_selected_disk_into_widgets(self, *, refresh_options: bool = True) -> None: + disk = self._selected_disk() + disabled = disk is None + for widget_id in [ + "disk-bus", + "disk-device", + "disk-storage", + "disk-size", + "disk-cache", + "disk-discard", + "disk-io-thread", + "disk-ssd", + "disk-backup", + "disk-skip-replication", + "disk-aio", + ]: + self.query_one(f"#{widget_id}").disabled = disabled + if disk is None: + self.query_one("#disks-summary", Static).update("No disks configured.") + return + self.query_one("#disk-bus", Select).value = disk.bus + self.query_one("#disk-device", Input).value = str(disk.device) + if disk.storage and disk.storage in self._workflow.reference_data.disk_storages: + self.query_one("#disk-storage", Select).value = disk.storage + self.query_one("#disk-size", Input).value = str(disk.size_gib) + self.query_one("#disk-cache", Select).value = disk.cache + self.query_one("#disk-discard", Checkbox).value = disk.discard + self.query_one("#disk-io-thread", Checkbox).value = disk.io_thread + self.query_one("#disk-ssd", Checkbox).value = disk.ssd_emulation + self.query_one("#disk-backup", Checkbox).value = disk.backup + self.query_one("#disk-skip-replication", Checkbox).value = disk.skip_replication + self.query_one("#disk-aio", Select).value = disk.async_io + if refresh_options: + self._refresh_disk_options() + + def _switch_selected_disk(self, new_index: int) -> None: + self._sync_selected_disk_from_widgets(refresh_options=False) + self._update_disk_summary() + self._selected_disk_index = new_index + self._load_selected_disk_into_widgets(refresh_options=False) + self.query_one("#disks-select", Select).focus() + + def _refresh_disk_options(self) -> None: + select = self.query_one("#disks-select", Select) + self._suppress_disk_selection_change = True + try: + if self._workflow.config.disks: + disk_labels = [ + f"Disk {index + 1}: {disk.slot_name}" + for index, disk in enumerate(self._workflow.config.disks) + ] + select.set_options([(label, str(index)) for index, label in enumerate(disk_labels)]) + select.disabled = False + select.value = str(self._selected_disk_index) + else: + select.set_options([("No disks configured", NO_DISK_SELECTED)]) + select.value = NO_DISK_SELECTED + select.disabled = True + finally: + self._suppress_disk_selection_change = False + self._update_disk_summary() + + def _update_disk_summary(self) -> None: + if self._workflow.config.disks: + summary = "\n".join( + f"{index + 1}. {disk.slot_name} {disk.storage or '-'} {disk.size_gib}GiB" + for index, disk in enumerate(self._workflow.config.disks) + ) + else: + summary = "No disks configured." + self.query_one("#disks-summary", Static).update(summary) + + @on(Select.Changed, "#general-node") + def on_general_node_changed(self, event: Select.Changed) -> None: + if ( + isinstance(event.value, str) + and event.value + and not self._node_load_in_flight + and not self._suppress_node_change + and not self._initializing_reference_data + and self._loaded_node_reference != event.value + ): + self._node_load_in_flight = True + self.run_worker( + lambda: self._load_node_dependent_data(event.value), + thread=True, + exclusive=True, + ) + + @on(Select.Changed, "#os-storage") + def on_os_storage_changed(self, event: Select.Changed) -> None: + node = self._select("general-node") + if ( + isinstance(event.value, str) + and event.value + and node + and not self._suppress_storage_change + and not self._initializing_reference_data + and self._loaded_iso_source != (node, event.value) + ): + self.run_worker(lambda: self._load_isos(node, event.value), thread=True, exclusive=True) + + @on(Select.Changed, "#os-media-choice") + def on_os_media_changed(self) -> None: + self._sync_media_visibility() + + @on(Select.Changed, "#os-guest-type") + def on_os_guest_type_changed(self, event: Select.Changed) -> None: + if isinstance(event.value, str): + self._set_guest_version_options(event.value) + + @on(Checkbox.Changed, "#network-none") + def on_network_none_changed(self) -> None: + self._sync_network_visibility() + + @on(Checkbox.Changed, "#system-add-efi") + @on(Checkbox.Changed, "#system-tpm") + def on_system_dependency_changed(self) -> None: + self._sync_system_visibility() + + @on(Checkbox.Changed, "#memory-ballooning") + def on_memory_ballooning_changed(self) -> None: + self._sync_memory_visibility() + + @on(Select.Changed, "#disks-select") + def on_disk_selection_changed(self, event: Select.Changed) -> None: + if ( + not self._suppress_disk_selection_change + and isinstance(event.value, str) + and event.value != NO_DISK_SELECTED + and event.value.isdigit() + ): + new_index = int(event.value) + if new_index == self._selected_disk_index: + return + select = self.query_one("#disks-select", Select) + select.expanded = False + self.call_after_refresh(self._switch_selected_disk, new_index) + + @on(Button.Pressed, "#general-tag-add") + def on_add_tag_pressed(self) -> None: + tag = self._input("general-tag-input") + if tag and tag not in self._workflow.config.general.tags: + self._workflow.config.general.tags.append(tag) + self.query_one("#general-tag-input", Input).value = "" + self._set_select_options("general-tag-current", self._workflow.config.general.tags) + + @on(Button.Pressed, "#general-tag-use") + def on_use_existing_tag_pressed(self) -> None: + tag = self._select("general-tag-existing") + if tag and tag not in self._workflow.config.general.tags: + self._workflow.config.general.tags.append(tag) + self._set_select_options("general-tag-current", self._workflow.config.general.tags) + + @on(Button.Pressed, "#general-tag-remove") + def on_remove_tag_pressed(self) -> None: + tag = self._select("general-tag-current") + if tag in self._workflow.config.general.tags: + self._workflow.config.general.tags.remove(tag) + self._set_select_options("general-tag-current", self._workflow.config.general.tags) + + @on(Button.Pressed, "#disks-add") + def on_add_disk_pressed(self) -> None: + self._sync_selected_disk_from_widgets() + next_device = 0 + if self._workflow.config.disks: + next_device = max(disk.device for disk in self._workflow.config.disks) + 1 + storage = self._select("disk-storage") or self._workflow.config.system.efi_storage + disk = DiskConfig(device=next_device, storage=storage) + self._workflow.config.disks.append(disk) + self._selected_disk_index = len(self._workflow.config.disks) - 1 + self._load_selected_disk_into_widgets() + + @on(Button.Pressed, "#disks-remove") + def on_remove_disk_pressed(self) -> None: + if not self._workflow.config.disks: + return + self._workflow.config.disks.pop(self._selected_disk_index) + self._selected_disk_index = max(0, self._selected_disk_index - 1) + self._load_selected_disk_into_widgets() + + @on(Button.Pressed, "#wizard-back") + def on_back_pressed(self) -> None: + self._sync_all_from_widgets() + self._show_errors([]) + if self._workflow.current_step_index > 0: + self._workflow.current_step_index -= 1 + self._show_step() + + @on(Button.Pressed, "#wizard-next") + def on_next_pressed(self) -> None: + try: + self._sync_all_from_widgets() + except ValueError: + self._show_errors(["Numeric fields contain invalid values."]) + return + errors = validate_step( + self._workflow.current_step, + self._workflow.config, + self._settings, + self._workflow.reference_data, + ) + self._show_errors(errors) + if errors: + return + if self._workflow.current_step_index < len(WIZARD_STEPS) - 1: + self._workflow.current_step_index += 1 + self._show_step() + + @on(Button.Pressed, "#wizard-create") + def on_create_pressed(self) -> None: + if self._confirm_action_is_exit(): + self.app.exit() + return + try: + self._sync_all_from_widgets() + except ValueError: + self._show_errors(["Numeric fields contain invalid values."]) + return + errors = validate_all_steps( + self._workflow.config, self._settings, self._workflow.reference_data + ) + self._show_errors(errors) + if errors: + return + self.app.push_screen( + AutoStartConfirmModal(), + callback=self._handle_auto_start_choice, + ) + + def _handle_auto_start_choice(self, start_after_create: bool | None) -> None: + if start_after_create is None: + self._show_status("VM creation cancelled.") + self.call_after_refresh(self._focus_current_step, True) + return + self._workflow.submission.phase = "running" + self._workflow.submission.message = "Creating VM..." + self._update_confirmation() + self._update_confirm_actions() + self._show_status("Submitting VM creation request...") + self.run_worker( + lambda: self._submit_create(start_after_create), + thread=True, + exclusive=True, + ) + + def _submit_create(self, start_after_create: bool) -> None: + try: + result = self._service.create_vm( + self._workflow.config, + start_after_create=start_after_create, + ) + except ProxmoxPostCreateError as exc: + self._workflow.submission.phase = "partial" + self._workflow.submission.partial_success = True + self._workflow.submission.node = exc.node + self._workflow.submission.vmid = exc.vmid + self._workflow.submission.message = ( + f"VM {exc.vmid} on {exc.node} was created, but {exc.step} failed.\n{exc}" + ) + self.app.call_from_thread( + self._finalize_submit, False, self._workflow.submission.message + ) + return + except ProxmoxError as exc: + self._workflow.submission.phase = "error" + self._workflow.submission.message = f"VM creation failed: {exc}" + self.app.call_from_thread( + self._finalize_submit, False, self._workflow.submission.message + ) + return + + self._workflow.submission.phase = "success" + self._workflow.submission.node = result.node + self._workflow.submission.vmid = result.vmid + self._workflow.submission.message = ( + f"VM {result.vmid} ({result.name}) created on {result.node}." + ) + self.app.call_from_thread(self._finalize_submit, True, self._workflow.submission.message) + + def _finalize_submit(self, success: bool, message: str) -> None: + self._update_confirmation() + self._update_confirm_actions() + self._show_status(message) + self.call_after_refresh(self._focus_current_step, True) + self.post_message(self.SubmitFinished(success, message)) diff --git a/src/pve_vm_setup/services/__init__.py b/src/pve_vm_setup/services/__init__.py new file mode 100644 index 0000000..1c25334 --- /dev/null +++ b/src/pve_vm_setup/services/__init__.py @@ -0,0 +1 @@ +"""Service layer for Proxmox access.""" diff --git a/src/pve_vm_setup/services/__pycache__/__init__.cpython-313.pyc b/src/pve_vm_setup/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01be44de1acfbcc17b21b9c98cac89bd7b75a639 GIT binary patch literal 235 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 literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/services/__pycache__/factory.cpython-313.pyc b/src/pve_vm_setup/services/__pycache__/factory.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..82bab9176690a6abda074d44746140092a887053 GIT binary patch literal 1020 zcmey&%ge>Uz`(F9YHj9hMh1q*APx+(KpCHX7#J9)G6XXOGkP<4F%~f@Fa$FNGkY_6 zF&8m|*(}~HUaUo|U^c5an-_Z#yB9|hhXR8HgAzk9n-oJZyD1aIv?9)Q4o%LNAf6`U zEpEqxg5cDWlFYpH;+KpJ3=Eo#xA+2z@+)%lD}qyt$}*EvH5qRSxg}<&qKPuy67tC` zLleCv09EB!km;P0nVMIU46zkLvO*|^&jw%*#V`~x1~U{f1v3^g$FKx5#jpl5$FP~O z1hYsnWU-h+6&JAwvlelrvuU#55`~%$b(vdYa!Gzs=u_&T@Rb#Apg!m3x)%r;b9R&%&4?0A94@eaS@Wq#!g9Lk?TeqPDor^$JXBR)Pa zF*h|n{uWn!d~SY9X%2|Z6CYn#nwSHX;g63`%PcA`$;r%1&C8FEzr|Brl30?NoSRyb zk)Kio3hW|KI2Q?m;!-p|9wHr|SW;4ynN(VmS{xr=BmxRJVGtnzB0w3Z2t%MjiGh_-1?&s}9B%e1 literal 0 HcmV?d00001 diff --git a/src/pve_vm_setup/services/__pycache__/fake.cpython-313.pyc b/src/pve_vm_setup/services/__pycache__/fake.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd8c7c892ae225286b33c7c0f8c05db65cb5548e GIT binary patch literal 4767 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�aZsJHzo=)fYyLv8AgckgjJRb4B&NQ*nH0s%nbHD zXDBNlLok;yYcO{(4}1`Z3(37)1l-F-v3tD`?!^(qp#BEP(?}Q|4)F}QeaRHeA1na! zp)pe^6I=%q14FQ&KVz^^urO#fM=)sEiU-NZJOq5qL$Qy25I**gL2_9rUcVs6m>9U? z5VvH`<3(~gF9DbHQtWbHgv+C4VbKu^n%#xZ{~`vZf?0`=&tNG_R%lfK&N*qIHHDz@ zRa zV%Ja3&r8cpzr|jhT9lcXV*nZ)11;LE;)4k2CgatIj=rsmJvi1m4GLX zz-AYfRB2-|D6zC8KR2-?Gg+ayB(bOj?g>rSB2aVo77J)H2prhABq6?!hXxRc2KzoW zPXz;0z06%JhLR@ z7GHdMVrEHvT7FS{Nn&w!5vV6z1S*7Yab+aNr-IjwrWAq7G)+G6_`@wWXtWA}O^HuT zD@iSaZuEhy1p@g7Vm)~BbscE%hP5m)r!@5zOKCx7$}L`~_lm%~T#6z<3OF+o<01T8 zJP?B+ykgKeH*{bWNKC5H~2;S z>pJVM^DABCSL$HF;EGKs>#Xl!dB86WS+@yVdIW9vm|oyfykTO!-FKt!7XK@PE*t$9 zW-Umbpgg5`hUw(0N%|Z8FA2IpnnD6f9~f8#xH?$A@G$U*O-PyJe4Sh2BDcbVkmd0U z<2Qs~SGB#UYI}o6;5v`|MIQMDELV86Kd|s~ir4D8@&cDD0u~>H7Qfr*b-`i8vnb$P>!@`g7= zq;81G&PbUNw!mzK$_lX+wHHKeZz!pKU}sP;`oO@VAT)vJ3m3?eoHfqZg$*tW8*GTV zZt8Q<)aOL_b<@y`rlBa7Sbq^>5Ky=wD0)Ls^p2p&gpkRSH`KIlNGZ(Mo2hqQ%J`y` z@keep(0VO4POhJyD;XJtTp53Skz){$`OLt^DSk&tMT1}#%Ks4eax%XgT~ zQQSkB;fST9tS39eF?mN3Pi=-1%3Pi-%qMh(JnWfI*t2@FFrQ@S^3-5HDJbNr%zRRr z6~xx&^5S7WX)5H!&U}iU)r*H2rDehVK01uhNjUiwzvy~77L*iC&>k%|V0@nv&fR-df2KbRXsFuumEQpD@P!_~GeOP+{ z*17^OPlmUqgW>JgRK`#iY-_|2lX1bERuK#giRR#GHh60S;wlgs4DWgdb6b@&FeEB6 zFd*7K!8~C1^5U=su9uG?n9mq#*+M!4Xc+*=duoLY_I^>m3+qVb#1APnu5rkUUq#@oly*`>mgMkbmUDfB(XRfRLzx? zVwv5}Q^-%sN=+`&D*~;{DFUqyyTz4Sky!#U;}(;@cNHtxgeqQWEvW#is}ycA>VcXe z3=9kp?5h+*GILW?6!J?;pxX~X^%!XTL0W#10?1hkn%uWoKs$)QwPTSzsIdZG4GXFR zAw%t;x&^X24wSfVu@>i~rWQap6(IMeuYoEkj+E5Il$^}GRB$DS)}b!C2vUN%N*uLv zRcB;iD1HwfQ@SH8KHYMX& zfw&v|V*S;f)iW$E^Q)}jyv%QSLs)9M^+f9#HJ62THn3b4Hn}6Exx#gW$_B9wwFknF zM<0y75Ey&eHSU5-{Dt_03tEYnrIId)B;61YyTc>U@73isp}5EYizEZTFwC`A`Bc70 zG4QKgP;)=Qa-i%)-~|=`%X|SBI0C@KPvG_uys!avxj`)#ZqN*k2Qvf1R0abEP)8V+ zH4_8CnI4f|;VhU6aOQ=NKq0pOfL6!_fqK($9S{brunK0fW@KPU1g$N?x(g3xej-2E z1t3>K41klNtijCc4AAZmsK|uNKo~)wMihhzCzTi?giM%%SwdM27(-cH7|j^-*h1Ox zb>+bwVzwZVMQ~Ff446;yxS~PjDOfO+D;l(S49rF^<`UT$7=rmixxoGgt&{@ojRC7f z6c?$Cp=Fjf#~8y$=TrSmvLIfFUC8#F+!LZnCdI48*VXwa@+pfM1ooUw(-KXt5+{Zvl9NI;8xB zj`Zo-fm~SxT9SN=#lzLn(R460wH3Mm~1C6u2oXE()@NzOE14ETMXfp%UxuC6PpzdH%DyWu7 zO;Jdygl*K)tAY(zDmVssLN^4ZrYL~7xfR_94T3Tj1%uq9<_g`}uaKzV73}W^S_}f- z;jfTZl%ESX58Q{XGC)#Xl3$>klUkOVqX62kp^%oDnvN2o~8t3{{$%W zgEw*)A?>69tyfJgF1aO8l$cqZ3hr7#YBumLx1tbGMFbjuFA4>*z_U7_Y>e2^c8ee6 zpJ4FzIPkW&qHvH(kiUw+6%`9)UsD*UPX$o`%CC@FBG5F7CO5RJmYZ6Vk)HyZC@RSS zFOIpzmR+7$lwMr)9AqNcSWx#1yju<&aFE&!)B%L{wm=lP#|7z3VosZ&Y__rhRdG`p zLH(@{0u0Kk9qjkGC2k0dPq&$Fb4N_=g1X0LG0zJEo}m6#O=nGa-33+q%l!5ST&}x> zTz3h%VQ6~Y(D9<7;{^5#61o=!^ggf%a*D$@-DzJE*IwavQ9R^;`UUZj6JZy{LpnTe z@Q7UJQN75cdWA>*gAy}m3gZnix#_tRb0_C_xPRebKwjOl!fl88WgSQG4v^%_LMaz` zQf}~ryLuNm`0AeGztMP+x?T9n=>QV-S!6IXs0C?7_&(LQxkIk}vQi z|Ng+t$eF_UtJs5qf#IOEzB3!cA#MRTHipA&B5usghoyL&)tC>fiG$d-#x4fTM+`vh zqwMCccFafZSlyVJkFjyNDKZ}u;Bk{-J|@HJrpT+b}4iAae)6>&YfGBqc@5ql;fG7m*OaSfu z1D&Ulm%5Uv=rhQUFOY3~U^XZlt>l1gRsyk$Ks%NoV;-O#6%ZC!C8*sDHKB?dv>h-S zydxtPCdLI81FbTL2^E3XLZV6s1D1oF3xY+O z7c32(sE%F9hNK-lgM%=L53CY)ut@AmMz@u0MVCRr0P21eT>-JcYehiTLI!M*O#`)I zkpck}bedd6pvEXf^aDsg*f!9B9%R+YM^J6R25GEALY#?_fk9LB7Ds$MXah=o{4K8d z_}u)I(wx-z_**>j@r9*{IZzq?`1rKUqT&)zlQ}OxKEB8eWCLiAc~K;Y1v;Ois2Ico zO|%zvfLMJXViJg00wOkmh}|IK0EoB+B0vpoNJ%6b9}g;4Gn3;JOG=6|lS)fci{s;q zltHRMO(963WdP!W2vCh$3^J#U;R6?gq3I1<$B*1XtU3>bWIwP9vFd!_U=Y#!zzJe; zF-Tf}0LgveVc_EX$}7aG^F@P^RrU)T7i%=*7YPEI(iKuegWGN2yuQN$7~9Lx+l9HWReSR@#Hv_}zJun6>E5B6YD=)oQw!D7&Z zJvf8KL5oJRSc zKeH^er1F+37H4BB%K~`-G}qR^aD!jCzoxV1I=}Wse(lTrx)(TfY406SvMfULP7z4( zmLg8~!**KdGBPlHMsvH?MSiWz{5lsnAWP_I8WrHO2;J=^uErV)K&|yi~M#8YAp$MT+X4s)zXz2)4nrcE$Lx5C- z;5rag{ecfALbT*S3U5hZ^-y|d9%%hzF=(|7Qu+ZMU9do7L+WLIn+qH^5buBzE7&hI z$P(b{06iW+g12O`Iy*5X9=z$Q3`_a}?fSjUuW^AxgZAzQ*I?-G1_@$K`{48g+IR%s z2vo_)z)&OtB0$v_O_MpejzxDSNDz1Mf;WZKfLtdHB53YJa0>w4i6B9&r64#YQ;`?T z*JH_$dKdZiF7q2);4pxs1W-W^OTnP}6PzJQJ!l7cw;5!;9<*2oHD92oOn{qpDAf>1 z@|HZVvV;M)f|`7q&>fxNnMqK) z8hY*;q^d0fDY+$s%QK*rP#su2qI!{EbwSEyev=CvCYT<9m+vMdwhW6T&~z?R;STmm+68{q3;ZD$I6@$v z!CJbLnm1q#9{3?tMV_GcIC{1Q^$M`oN8q!k`atE4FGwG#&?(|!1fAc;Qp5`q1~r2r zT@z4`rsx|;)ejKy9Yp*D5x+phZxHbZMEnI2|3Jil5WxU4g%L!21&tnw6fuL?EFgjv zM6iLIqPd_mia;&LA_0&fs4-InY85~_Fra#=2vo=yfr`)~P$5+W%HQBz0#02;pojq< zuvNqkG61cU!U+-s5uk=|u^$5iLmR^lZovkZFD#6#a$ndOS@l4KE{Mcd-jNjQ9S!Ka7S_HB{lLa!=1Mca8dy1ewqoxw5BbQcMQd*Q6 zA72E@kVOwboj?BM{G6QBWbiqkdWlKNMWFl%9>%)G205(NLo&Qc@ek|3m-VR#R58Y2Yd=i5omdM5oq`r zyf?cDv^u5;RHT5 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: ... diff --git a/src/pve_vm_setup/services/factory.py b/src/pve_vm_setup/services/factory.py new file mode 100644 index 0000000..1421d3e --- /dev/null +++ b/src/pve_vm_setup/services/factory.py @@ -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() diff --git a/src/pve_vm_setup/services/fake.py b/src/pve_vm_setup/services/fake.py new file mode 100644 index 0000000..c499c26 --- /dev/null +++ b/src/pve_vm_setup/services/fake.py @@ -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 diff --git a/src/pve_vm_setup/services/proxmox.py b/src/pve_vm_setup/services/proxmox.py new file mode 100644 index 0000000..7c8a305 --- /dev/null +++ b/src/pve_vm_setup/services/proxmox.py @@ -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() diff --git a/src/pve_vm_setup/settings.py b/src/pve_vm_setup/settings.py new file mode 100644 index 0000000..d3171e0 --- /dev/null +++ b/src/pve_vm_setup/settings.py @@ -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}.") diff --git a/src/pve_vm_setup/terminal_compat.py b/src/pve_vm_setup/terminal_compat.py new file mode 100644 index 0000000..8baa432 --- /dev/null +++ b/src/pve_vm_setup/terminal_compat.py @@ -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) diff --git a/src/pve_vm_setup/widgets/__init__.py b/src/pve_vm_setup/widgets/__init__.py new file mode 100644 index 0000000..05649f9 --- /dev/null +++ b/src/pve_vm_setup/widgets/__init__.py @@ -0,0 +1 @@ +"""Reusable Textual widgets.""" diff --git a/tests/__pycache__/conftest.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/conftest.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f39eccb2fe33873bbe49201b06c0cfadadfac772 GIT binary patch literal 1132 zcmey&%ge>Uz`!syYHj9AMh1q*APx*OK^TmmZ9pm*f*FDty&1e1ix?F^Y$k7}BIaO* zU?y)CFVr0SPnvA!&6Z7)&OA<>m^Ye=R zlA#uWSUk70NOb6%q<6L81z}xe7U%WvK}Y`9Z21MC*t*4$n^=^cT2WARi?z6I5R#uKPM+O8RV7t-29Zx zv`Vm@MPdvL44?#1T)@D<(7%VrF1qxW$oFnwe9Q znOA&^$DyDy9?p}DhnkaET%1}23JATT)bgUtlGIzAaG_i5FflF%i1PTvq9Rbr28Yy2 zh9YqW28JS0uthex`6;D2sdhz53=9mQR9Y;;z`*c$8C7{UMm literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6380cfdcd91bb72a9d1b94eee05feff2cf70986f GIT binary patch literal 92526 zcmey&%ge>Uz`&roVQnV64+Fzv5C?|YAPmOOGgufHrZNOG1T%Uwcrg|+DuCEb-b_Wz z!3@Dn-Yj0M#Y_qeUTj6|P#KOQ4zLVo5oa(%Ftaz87k3diRF0>JCzv6a#hcfQuZYi! zzla|!&gw1TC0HZ~X0v$6Jp!Ye14-X%xMNB1B&u1a`P*KQ;W(nlR+kPI~EiKrv%cF#}ApGcYhHGcYiIE&wwW7^X5r!{wu)wlOdSv4B;h5K0UN48e@SOqNX1 zAXC6{utG4J6U+q_x=;qJ@GWDQ%BsMS&S1`*#;D2c=eCmZmOxH^VoH2Ueo1OxS$tY% zPO2tr5kCV1LlMZew>Z*@@^j-;^U8|&Kw`oS3=Eo#w^)i(bJB`IE>*bYr5{?HT2!oG zkddxmo?nz*T#%TYs$Wo)S(aF$pPpHwS8Q0Qmy%irR!~rus#}(;TMUX;{gTw;l439! zpIA_!S5SG2JsFaYZi%A>ogP#|l!1YvSeJo;;Uz-@!yP{HJ3Qjoc@!@3C@c`#kb05F z<_?c!xBnd;xyw8XpV?XXxjr+naB}@D28Cafi5cTc20u-vTWm$CC8b4qMW9MplkFBq ze0*MFZfbn|Ew1?Z-29Z%91xo)KEALtF$XHcA0MBVSyWt-lbM&AmmeQr1WEw6MC0R& z!Ra+Vv81FZGpV#BwKzV$NP&TY;WH@Ui)2AzCI%xw0a^@-tTu-4OsuS2Md~n_mplv% z4C%9Gg1}CDO{QBMi6teexdkP~w^&nBa}q0)VQB$GgKE=eP^yVz1SKETR1?Z%3W^s7 z1_ogUk3I&5JeE*~AZXPKauW=PGQ-q)G6XYZFf%ZeF-~RnW94B`U{FKxxp0vd5R9z5L*TC=>w{m{5ZgEL|QDS=PEw<#;f{e7{DwB}R+|(3>{L&JI z^2AI~(V~!+U!+hDE{zn5Qqxk4QuC5i6;cvQ5*14F6+pRCPm}!?Yf)ledg?9Kg2d9| z)LWc|rKv@g@%eeFMWFDz#afn_Q<_@D&A`BLi_fvRIJF2=M!Oaj<%5b_)`HBO{E}PD zi3J5knxM3%1yaWtUkr*}1rPwI&|9J)7saRL7sW%I6JL~?m{M6}&cMI`O2Ne)&=~+;`4p~mC|uxBc)-Ko@7w7+Lw}9wWgf%pJmwd9%y*bx z<#D(nAUvISBJYg+HD#9t%&rUATokZ5z;{)^4(Jk z$j`th`$d3(Tk?XW|7EU#3oHR2`58DBe-5$PsOrl8W2fq@~IHChO+3siQ&I8a)N0p?R{ z1_p){c@$efg&tHLj8bBV1{HH)F3im^S0+lK=#~I0LLso}_Gb)c4Q3?HY=nD{`XH)x$fQhZ?O_Gb)cC+Hs* zSm+W|2~XJsRk9InCcKOf=FsERWn*B-;|%5u<_hIBU7aqVhH7e z*$Sgm8G{*Y8S|KI8S|KJ8S_|d8S_|e8S~g|84KD&8G=A<2801%R{jR8B?3<{cfOAj zLnuo$s4W3j2`>?Dhw}O``haf-w1DVH~JXmW-crT2Z5UXFhM8{t4VRSy>;>m>CO9AzBIT+m8 z{35}m3kQQclbbJr zw4ni9(m=BqDro@|vw#a3!-S0CLWY{fdhl*AS1s5$Q_W%>u=!@1#hP5TdPS*uDXB%E z;YY4oO|Du6TZrw3rkcgKn2Sq_ia-Tw5vV(&$$5*ppwa+D7=j365Mc@;%s_<2EtY~x zLjy2r1SUM^$)&}qDO|M*nZ*#J3}JpXG=!@(gNd2Jg-o#c-vZ`;OR)dJg99tsi)=vseddBn zBal~2iX1>=0FF>GOR%#If1&us80s72TWndSd71e|x7dnPlZ#SIsyLMk5{rw=^NUid zIFys~^YT)YOR8iXOG`3R^GY(46H8K46cURSAi5k15_9#c*p)$}W2MEKj8$_$0Ri?E zA|jw^!xZi@V_4dNN4gkA_ML4*rv{8OX| z)Nj1SQB;~24;sTPG5|>#f(THGy2XV^eMO+I@hvWgf=ZCW_{5@Hg7L|TIXUq~sRc!# zG0nuH)LT3b1(oqhrI|S;nR&&x*mCld6LX4hNyHatRVuz+La8f8T0yz%U3b@5uP?TC+tSJo1 z0VW{*+#vfQY1-%(62}CYV~)(Rgt!@^$IuXkXM(~rhVatzi*gf749!q@7RW{#B6Cb3 z>Y(NuAWNYdXarFRH6F#m1`sKz8AipRp=;2P7aE3)fN6lECkHg45MP#=S{|Q>l(38A z%QH(d;=zMyMR^Pi4Emr^=w4>XDD)F9$;({Q4bBhvh5BncYx?VY>K2GXhsU~euX9LW z;E=u{EH>SCqU{Bazy%T)I07fwE|6Gbc3Ify0!QF=VcUzsAeo-x3&OUS`Ry)n1Rl@_ z6BqdduX8}rWsblL{C03IgmIN4u%{Tpyv`AL5rqd4zQz$a!5G4XsDSaVf?NTSxD0X? zgb63Fa0IR~gYzyTcvm?BF9;h!WI%2bwneaE?z;@~0Yn&$yvz}JgI}n>y0iKMhyDEA znYkA@>{obS;IN;OyTW^q=w&(k3mo0${--uU)NoCi$nSo9|Nb%cR{c~e_co2 zpW+PA$gq5rJmW&fNGrBQ%u(`;OGTnsnRhZq+Oq8m(2QbbzNpL?#lUt^#e+49mHDP7 zca%KiO%ui_W|o_#Y#_ERV-zd%Ee0^j%orulc#D-SN{0OwH)9kZ&n+Gv5L<#hN*;B9 z88rM39$?nu!#BVj4WEXIh7PeZVC}deruIS^f|)IuzyrZh$QWS^ z+CVXA2o^q^90aOL5lX=yfUZ!)a1py63wU^i6=^^Pp&R>PCVa3J=3>ZzN`VQU zlmQ>mgy}(~3~h$|G+5lFfKmy^kWVF?R8J)s;Rg#b@N@_w#;~Un4Bh^W!JGtBJ!UFl zCn1%yx&f^H>2vSCW6%tJv3XUoUq!BU~LYcf)3`Vdg}y{uCPrJB2p9f)(M7g zf5u>bg8o5F?g~Js2zi4AgM~tQp;Lr>B(zodV4(=35p5OF^k5#REn~rIA_vb`fd|j0 zaxl2F_$h-)J`M(VX1{+N41OYH52OqGxfMw=FfgoSyv1G&S$$gN4jHflj|iK>27eJ_ zq&NnX!Q^9P9z=kKCWgGS+?Vo1EJ zAbn_xuW%^Ok%Ws~MDVV1C||%Z`VxmSXw1}dqU8k+jR}@>qAm+-T;R~S!Ve;^@@rh@ zKtUHdG(ZEJP%$LlRgi8p#aB2q=0w58E+TkWIW#T^L(PE8U*XVr09n8|C-bs^MhEK+ z4&Lh=QWrU-=2%{rHo7Ejw87?p=w)g5D;yqZ19)iMJABFuTrczKU+2)jz@h&@SZq4j zd+HM`=R{r>R=>cZeuW=IUgcN6&Vhn1a;Ss62Ngr&UFA@pV2P&q3WxffNVwQV1n(+` z`UPRA8F2Y49O|GES#V^lbX3lfy3DU~fkWjgzshwE6m*e81r*s(F(lqq4wa5dG{sjq zROU#*#V#UvS2JRgLj~jp@TjmeWK`IBh5rQ(=NY*x{P%=jmUF(q;e0{P`2xT5 zbq*-H%;5|g6^8O4!dE$*K~98@tV*3V#R_ zPF~?~-V+MvT}1G%g53z0L1uiH29Hv9gNH477&v8aaY*0d(ElzB7KRL7_Jam5ZDNEO z_c2Cmu&rJwlZV1A=_ycArRY) zG1`Rfw7I`Rv?cRxaqbvl#@kAa(dI0-mDxaSZAOr|9+)%)lO|x&oDn2z$rdBPe%qcg zhKc7615bn-v(MK`jWdD1q?_ zDX=0GBHt0~G9cDq*8Ef@hETR>_*fylZ&4D;?t{9#xQT%w8s2lo)>lzt2xbE>FJ_N$ zN9tQ}L@U7@gMGXQ=10&RY6@u77-5SVOeccIIckj11?vzYG%+CS@@EX@K=KK8)$n)> z9-8ilkMk1rDST0FFegbd1sWemggMrjLP}TI`W6V?SmOXwH+bZefUZ#Z*kv#mv~R%~ z%pJ^wwDeaSmN1aQ25Vm#=h9!$l3xzUvfqMqqWTt0erX&G?u>rlz~nWG7Uu9m`x8Z= z?PR|=l@kjJbd&N^DvL@$jZQZZ0d9VSG8SZA0P$^bP*WP*_y%pC%FQoJEdsZ&`9Q5= zR&W#b7Dr-o324_>DtfCL+(-vCt&tk;SlaBG+-ME&TWpa1D3As?{?;^TwhO5>4Vl(A z2DhXkhQvc%mzkdz4_O;g4B68apPG_c0$M5*pO%@LlTy^oz`y`3aQ^MTdoQ$XB z7`=2^PRX-@*y@ZRaV;>Z%Lo$DXY*!bKV{13Wx;dGjK`ae{WKH1Hz$rp(Fb^=2)yx~ zQHmj$3Dm2E6ezIWm!LgHh{@1AMv^9knS)s@nL-(ZSuL6JK-0UVO$ak%pC+McV+rhH zER7{bczCd2cU3UF>cQGrf>*&KY8r>48a_MjCmYLvK+7hs{HApoZ#JC zt6=q>J7Y>00|R2+c@VtG1Tz~vF~%9nWx^QD6;X-Qyy8ZzJP*QJ3B!B_ZeD>ZZTK*A z5X^dnB@9YPjXaopVamYMl?dJNp+AInY`Xm!LDerwt^_p#5%$2WM_7Z>oP$Y&mkuCw zVRap1!GS+xFb`6AV^H@pBIY14A6cR0fpe0k}gMOc=pO1aM@r6tRGL%u)D{M6$3y!?{*l+uD6@M_-poXoP+c=*2Lc+h_2Vm%wk;#505@Bt8+ zdFjwY6JCN^oG3>dfI>r zY>5R0sd*_yU7&8jL=e#n8ihsf4S0%_5 zQ$Pf0t;#^8Hz9DOrdDJYgHASxFG)33IjCJMrz3z%E-rH4Bu0aQa&<;GQ{$N&JKaK z9l#}I6(nGxr*srsVFc_gUU1066s%;1R5~o6GPS4=&=c?iz7-`a(k{rEUg0r=hPuWIk;}q*7kKmrJlr9{npBjTlAek!*m=Og4wWxj z0P-VBKraCWbPint8tRBnkQ=auGw+I&6=4_TOs?>lLc>{o1+=kVoBtj z;LrxiuVeuy@S=sF@MVXlF-SrOM8;iwMC%pUj#~;MWEm* z0`<3V@j~0D$*DOx@$oAeA%O`R?{)yURB!R4HKL2!7#J8p4e4S~oB0Rmlqe=n^{*UE zoEmV#0L0Sy&cVc~4X1s>%QAkC4s^Z9||6TI+50{6lb z_@H-DJxDox#Ji{g#06D&MWDT4kPU#KR(28C6~vBr^Ma!b>}bSxyrL$M*=8Uj7DRxm zL*%6<7DeF2B;Yn5VlfG5*$mR~w}JvBiMdfHpB8OnU|<04d@F8bf{clOVUcGwxgw?W zg+ZRxmvh~ z0M~brt}h_sy~s<3FQQzWTo?FN!J?l)qTfXrShyO^zJnybf&@UyK7e>Vz z7#O)4Ig!ObL&b6Eo`6F)R2*hE+|&uRVB5g1N45>-*pK+FTO$tEj^S9SIEHoL@cIr4 zI}97aVFxx1L4a)sg$y{Hz~T`9OCpc>e_~_cRe)~&f-IH*CB-I34sSEYgItW>f@}x5 znL%tmM}2P@=0moO-Xd&=>`c97m=7B>dW&-$HgV_imSH~4!0m0uc$%BhTZ-j04;zRr z!Uz%<2a{5aAQ2fhZ)5h;N{rsBJg1d;Kx}<>Z!^?kf6zu+@P4mf%&@f(qr?87b`E&w z+5imuN5gkv1z}x{3LEOqV-01&H^{HR5Dhx;4&)Vv2qRci3Ulb6So=a*b-5TA^4Nme z!3VQ3As@_^vXqE*6CB`m6Pzf=vT>0#8xI;XN6d_a8YW=hAqdbq4_Ma-Wyl<%3nq-v z1R7|E4}$wM26G|Jm}6IsG!U-G1m2y-8O#&ROU>E$f_9>YfEoRI!Gph`Lol9mF!=G3 zy?&Pu#{e*>b$~Gd%tXeZFJ#adG3X27BL;mTgTmMceR*$%5~%z^ZMb5jAzskYlkv&< zdC8?kMX7luIhBav-eAzVyO0&iFF{SrbYn0$Y>$1gDFNEWL5%qZA$Rqm=|~B586?gbHu00j4Nz`ZX} z)WOC%K%%#38Gzsc&7B~p><1CJ2Q&vde88PQjPL=8-Xb*n07@yOXFNy(Lm9&aMH%=& zRNM)T@BrpO2PC)yjS-L_(OW6B%&D+3)q|k%9sIc!=b$BMfCoINlSKUz4>WKoh&pfy zE%UJESMU%7=6D%M_*OCv!x1)ia1<1bpz#viX%WvTU_c&Y#Frq!BVj8+z5zK3Z2*zbgft~ZBxqRbEXZE`sS$OY z5PWKT(K=9_vmP{hg?#`JTs;yw09dpEWY|WKLy!jjxRIJtMWA{P)SQA1_}vf?>0tfJ zLdt;O87u>Spp}vg3=ALaS2EsWw9#a&5(EvzfzDa80dMsJo$|$=nv+;ioSL$d39>s3 zv@z@#A9PO`NV%zIaS>?X4By5q{NrPwR>duzg4Ck4_+-!?rqrS$@Q@kkT=61e$H=&g zKt9AaMs|y<7<9WsYD#=!Nzr*wScAvQK%2E7<7JTDSSCdkpeVq)KGcEI>|At}fq?-O z&&7Vw#iCzWWLZtFh-rLbkYzRbXve@W)tx&ShQ*$UN?sRLyCkZ1SyZEg3$c*&3Cgad zCqiP^g_JJ|DPI;+UBPux$f$$;fuzg@4w)Ikowe6FWFY7ghfGH~gb63FqKI8Y@UDV1 z!^-5Pb22b5yKqdcoPV52;U%Q@g93JfGI;sjsVLBXj$SofCDbP*@m#nD4^ z#|tP1VVz1S9n2aH-vU`5j1$fH{NN z^H+v)ftP*mh7Fy#Gp6`3Fc2E&3FZjpHen3rjF^SA?2{`RKE{K687C~|f~L8^hxUUe zy1?ED0@ZF{7J^VBwCoe18#G0V&(+wIe3g#xsm7p;!ggsbYi8%cT7KY$up9o!8 zU5C>8xSm-q=;cN4yBCgpYO+REbznNyx461(gKNM4Sr116_K_6U-aT7s>-&ddV9N znzaJQ5RzG-Nk_04f>2@zg&&p+88a&Cb zS6ot*S-@4RfqYQ2k!CS?dB!b%=;cfCP?uw$-7ne#YX5@|>vd*eU_d?8*aUj0u}PJH ze?e+dB4~-CLU3hqNowvbuKZ%S$&wT;1OWGHK$i{N;z9Nz#22h!KN8*P;ltM7DY^mj z<82TD?&yFP1{8sp0f5GvixNQ+prrwjb8jJ?A4AmRa!rdt`}I)P2|yPLm?QUuN{dsA z9G@I7ua4ga=k3-*1`2aSn0a3{zYN^3mmSXgL(BY^BY{?aJ|lNc9Gxg0*C7b zezVK`78f{NcX)z{i~O$FIiTn=NZJC3V7#jw zt`~&$Au?dI4UpJoNNlK`mqD(A2&0h~Ib3fD%U&1Ox+tu5fx{EzF|Et|+7~!Huk#yT zS028*uqP`pfmdqDqxdVk6FK93Zn6q5aWCO7c8A0ME zVA7lsBx1=HD8PQfo-vS#=OP17paApM79O73l8Qwa3>0(3nq-v1X>6H-eLeS52E`>z4x)ZQ9-;uP)<>$JKupAND}2xs zQ37MH9}qD{PW^zP8$67HuoG*of;c6BpwAJ*i-gP^koqB55XXSJ5K_$vT2;irz>ou9 zuo=n}1UkME6k$jhHe8Ll2oq;5M0%@iG}n#RLc_NfBE3~Mnjc2Lmo>o z>8-NSUdm{zY{>Ugh*}=R=XZ;P!JW@93ru=(Ft~I2HG!|8*+tO>G$I&Fg^ECjGKPS! zYFWuxRYv?mAbc0o#OLRwLNBK=fn8K?3cjUAFD0|MASbaB=@xX*${*06UlC~aF7ys_ z$lW#ItGPj263|xr6x{?ZeYpj?)P`L-zgRc3IKN5=#3;z9EY3_$%+XCL$}CF-pLl(X z119)O0Hh>0H6=4qH#s9eGdcAZOJ06ns-_g>%YZ;mzr_yq8?mc?!0veHg3wx=x>7dV`Elt2j7J4G(=TO(iGah<~%bazNGTnz0}ky?nvWe(>Vu@EMlyu#tU z#tqK9h~RkvoG+QBVSN`ox>hfvOvuh!WFLT(>h=efV162AU8pT(a4J&_HZA0U63`rz~R+VdqLK4i|J*4%L^P{*ZCbU z@;hGO@H!9*A^5$nb3oB$4zCORj&Lr7ah1ahboC2V$wdSYBvA`d1JWpK2w}s?D;!>1 zOyRtX2;NnYHI@(=kWQGDmqCt#2&0h~IlLZNxpaW9|Gprmu}1AOhv5|tbF^E49>CJ3 z%Z%6y94<&{(*>3`U3QdS-~bW)F4sAr=rV^3IHf?j5b>)VE+Ah+#V#UvAc3JaJ0tbjdUmxcPUy}x5T;=ct z`3bsg?IMB)lBk8K0WE9;rA??Jh~O0tPf*%~id{tTK+ER%Eg_0PIzed@r0O!rJrH3u z@*;=l11pEow23G;hkV)`PUYrzbF`J4pqn0Kz{GcbuoPrRAZX`plT9r%<37e33AX*r zwannV9%?i}YdCA9L2Ec`G(l@PYvkAtiw1MmXfj`uXRJ|VyQToQDJ+j9VAnu z$^3_%yOx>pj}T*xI?EqnHV|8y5hN}LCKbV?3Yb)91j%Z$)iSXE(PykN;`w9117h2- z*D~YSG`LA`bfqVFcw=;>Co~vFS9&twnh!y&-C`28Pl71`G_a z6~EA}<)rU7z{qcsSCaB4YEVD=n-hUA$+rD z(PI!7u^Cecrm>(XRW~)SC_g8sh!eC+lOHCLn34iAwTc@iP*RXvC7_&}nwwu#shgXb zr(2v^1=+Y+#SIhAF3znIf^m`(b8_%F40!4`> z$XxI?Pig9G^DKG}@-&gRS%JNe*y0J<@%aMxc1;`1?V2g61*v%{sd**wkXVU_1Xg@v zUP?UF|M6*=p#7XhuNfE^v_LyI|MCpEotxelWQ{Ixc!PFc8EtX94BE2keVyO=BERzm z4(|gc5Q5+PItLV8=J37%+R6##Ld36fc!PFcLB%d2cp!;dh#HVaStAG=PF~^g-r@%5 zT}1G%f~>KI$bfXhth@|z6hs(}yvX4V-%jQO-cIHN@|58gi_82r7dU*b^SfN+ce%jf zbAkmz@cUfnfTGJBJ{LfH0HIun_*D)ckf)$x7ZE&=L@h)ONF!)F8B`HO@Ct{|77GaT zI)~3i1n(-y8XJfVNGHt7%OFQVgwe>096k@M;oHg7*GOFEFuKBFforRy4>&Tx{)R=S z4=gf$4wPQt01@c^c1E)kBALuA1IhFW*64V;j> zB;qoM-W3iLTyZ%pGv=_0OV|cu_Y1Q67dYHO2~>ZJ=w;9zarf)|_80l>FL1aYFo6*K z?$=rV`<1<>AcC>J7rmBSsBK%rt65j>DYEkq4SqpUuJ4JWT~xNi}K^DZKIS3%a8 zLu5cYVOCxSISL|-MqcD_hvzz1(5`7$@UCf(%lxL`J2`AG^4nhEa6KRbA<(blutmEC z<0^+M$V<@MIxZr3Ac#Sapk32YH4w2Y9Ik6TAWX;w9WWl~f)0LDhz$6G4ycWn zL2iNwqmdUmTp!puL$cfjF|9QompM$Ya9HEYa~|NB^gxbD4_Hik95B7W0V4Q4u5&=q zWeyK;OhUO3@v9sjAa6s(E+Tj!iCTynkVa5ULKQ&-uW)#PViGEL5y86(vc?>u2&5Af zlOR=>LGFPFqmdUmJm4|u28u~Hq?mNO&JT%6w*#UVI6wry+jR~oy3FBrf!`L+g)pvi zxPiO`RdNx*14-0E)LiCp1H~j%4Mgk;hZ`vFpkfyhysIEvOd*PpZM+O}4n!D@yvX4O z-_h@WK~@i%;qScbD3mh)j`E4$O znl3H}ST1mY2v8LRJxc(5Xn+mc83CZv2#VoixQ{7_hpT}wK*toMLYUV%TtLSZKzZO} z3QQm}$TnUEIR_$)MqcD_d0=S^$#4_$msDKlFu1~Dh9|%2UEl!cH&{f{Fu&=cWfD++ zV}px<@*9*#S$=Z`MI@-?ghiw)EFxVGu!2fX)(hw*rwv-M1u7tm;bOQ;&Um;Q2m@4d zrb3v|k`u}Umz*XL8F0x7wGmWuvO$cr4V-$NO}Tf)2RKpVu(A93h^;$h&Ffe=C< z7Kr%n4^ai#IR2D@fuYI9F;<*$AERT88ryzm5c?pLqrPK|Eok$&V~i$f_c(}mScK6r zMhA2U5EF=dL7dSsMxX71gfEzTSBlXw#)$2%bfgeS_M0xFV~i==H$4dVU;qQ7V~hpc z!2m`d5s>`B7*TGpRWS;Tjxp9O2V)f3Abd?mh`bJz)`!wYP}&qqTR>@RMuQGkx59NmnLoG!30+8y1D&Sc60PB09?Bj*~f(WOm;YSIAbB$@zpDF^5nL2x1+ z-GD)G8v;3#Xo6JVfPsiHayDRK==Ns}79{BNVRJYG&invd3O>3416&%=?3%{W`e9Jj z4@6BWv-zFjU~p&iivg216x}>7j4^rqO9+1Rcu8WqZf0IVX$i*MG3ahpw7KJ7h#Sj6 z`Vv!8ia0qD16)yA!yN#qNUXVM%4l2Skv1^1p zv0IXu9$%DSUL2pDnpzMK4kPe%Zc=GUNq$~@S!Qu&Qch~odj|3@R2g!wVYYj1dK$kQpPu zc33n*B}SC_wk3CrKI0u0#u#CiJFILVHZNn0DDxcwFe%Ik5)oyK(P6(M%@`xcb4P{; z#MWSs(MLV|5>!@#ha0unM~551Ev+GS_GK_Dj#FjWu-w|o2lF2eJ?w~X2dwiMqzKc4 zpkaft3Jjr4LCOem7z-wk<@QfzZ1*_jy98l5X)Hf92+J^5ep-+g%pL@t#~#e84_;Gh z<-ve-PkYKw28L)In7SZ%R{|yizNMW5d`mkg$}R0&(eQ3rkP$*PjD>bLE$EhZge~yB z7%1-_*nq00=R8pcv$KbGM2wI6Dl$86=K~pOT&0tnAU$hOF0m}8zy)$_{ zp*%r$Fi`{zzVGHrD6bDgekd#!Q+5%N!URHDO&Eg(BYaH|1CT;gPhmV{r7*ZIf5u=T zq|nBm8i-F}yfD`U3+pk1?*4|>Nve>CxN%nvgQMkCIgD~Khk!^G^T%faBz z3c8TRLbI5w8p=?x zRRFQfU_xe^#ZVy=n2-rv2*({Q@t{SsX*v1jT(x@1`FUxX>0GsX>8W|CMTt3FwR$Cq z>BU^N3bqiN3=K7li<%f17&O_6K*=5HY8D5OLmfecFNp915&ocFn+Qs;ExEL)C^Zk- z4P41sr4RA|4yQq{P(fS|W2#wviw9~#Jji7$nUS0Ux*X;gUt)1_YEcO|T+B6#!I#Bo zDp0=K7u39gtlb57@nS&U0~PJJINO8x6Ixo3l30>j9FH9IMZXys7%D(LQah$0(<8NZf~*1oExU4sE?ec` z?&s~~h27b4ox}Vhhxru_ON`|qqSyK5FY?P@;BdIkuXd4N4b0HH$gc-x7+>T!2HkSO zzM=90hr@=-i%6GUTmoG~VT^Xu1n5qSGPoG%vI{5=d})O|LAO(*pBFU==oYQ zpOxbF6J|WC%IIs(a#oEE#MWa3i5r4R6EJDc2okqs^Alh{YtQJ%#B+{;$4`L$91pvn zFlt{0l*Pe)877X=z6`A00&9&AiKP>uvJ~cUC>_j-*^|j*31$mr$wBmIn1bY?%8@9T z&$0AFSb}7b6hfJKtif#gsiAB>4Eg0K?K}7~iy-*9-cT(JptgN5dnmgJV=zZV7*hL= zGa7#KH@4mlmL3Gc7MSG-OF&B;;B7CMG`I(W(1oR6q{I*k*9D$HAfy`6FCe56-Y*E| zg0}BCg1LivLOG!AJI-j(rF38)L?g_?+5-#agthBnG_-xkYRgzq7R-yIx5|f^E>KSI zCN)K1>F4FK1~cZ9pCb52Qv@wiL@2J@OG#Xf=3d(5UItMA3^tnN$rQ|x!OXx=#yFMP zkClf(fgzm%y#H|;m{bFkY~bG66%Gb>M!$0u-IOVS(MLnQDboN?CnmL`ATci`H3g*? zQ?v@yJpTeBzJiD?AmRy#0IgLm`UL7>QR^m34wwsyK>Zb>dn2Gy4AL9n!rB`F`v80& zCAh1R335EBzftrHw4)K!T>|wjAQ;laFhK5MWMrmf=B39Mr{<(4m&7OM=j0?76oW3L zEJ)3Z&o4_Y%1Nv&`p>|?Fa^}b$Qe3a3?b3!x)XI5@UAJlEM(TfenVQmgZ&DJ1X_3E z2EQ1rJE6Y7V3mhPVU*kFl z6kX!b00#n;3lYD{p#cg6sMtjW?<$h&%OIm5>XFII92(y>!MzT&zQ`wVhXm41fpkbF zfIB4q_Kf=&{YBXJGyB_vJ0$+9pbm+@IH*J7uL|mr_)D=J7B%r#Wj?~m=r7B5gv*7) zUzPcU5Tm~W+X-Pe1AkTK^UU1-_KfHG82y!5&hxW@*y4;JaVan<3nmr7q%tE&R+Y`) zhW)%YqrV={c^w`Q+k)NS9<_@CYT$yqD1W#|yC~q+A!y(g${74zlwj6S<{MVRq*MZi-gc&1dabX6FGKxqbH6_^#whFHZ0t6cL~a5R+Jv8cs0ZNw1`@3z3r zWea7&)`iJ0$2vjNidF4QtZG+;a#=AkFy!xsb!prgQ}h`aqTyYcAox@Q*xO)2i6NLX zl-q+c1oH*+hw?zX zmb}sC;3#51NhuaEF$Ar|5DK4$fSC#HTC&?R=5g3E=5gCH7R(A|!01Dtz6DXzw-D819o~f}Dc0e-z!OGLyGfn$h*)ekg8!RiMZ zqG0s{4N|))-l$0u7D7z>%H@__P7mN$(eHWbt z4L2}WRf0w_h@UtvN=?tq&*Q4qtKh0tumMlF8XCZ+ok1+eOzK_d~+8S^R%yab!_Cf83KkkHL9(qz2FXs5|o z)kmgd$}>|+GPr6L>=ZOWCV+w#vH}3aA~KL+W@tg;$w;$UQOG z4U%YXF;);ejCG3@>~l>atYcU#j0_A#tRR97M1V)Lzyn#}xpnZgI%ul>77tObrE>ZJq0xAC4^;iT|ogd_+%nmoTS7eXaX;eF9IFuRTQ6- znpP5@pB4{R14}zae2feXn?XZht;|DamR(Q3gY7zp#6=E?D;(N4IQXt}NM7WSyuu-k zHVF1WRAPGU#MlKkTf{DlT6A#TkdU6wJCk=o{+6=K5_TQDH-yEe+fKAyAiqWJvan?b z#{+)J>--8A`4uj3SYPK?y2!5tVyIePRJ8;%tS_osU*NF5plW@Q-}*WS6kX=9zQC^p z=Rz1)IjlRXAk6C=))x`Ht02`15E-mGuW(o|h=s^Qbi#NbovM})8IVp@sQnNTIC+C# z95Tjc4)crobrsW#DyA1W%&)7MUsN%_z+rwt#rz__`E?E`y3ApIfnNd6g)pvin0Hh_ zn2<#aFdj&v9wLEN;}s6`1zr$&h(;LiD#&6}hzv-l3eS!3@WX>W-iTB_PB_b=M0Vt{2onOUU7eIe-s)P=RwH4AAis6%Z!mmoDRFx&<_HW;o18XF7;j}3+^fyM^IL1Tj!;Y!R$cp1ZG*^cl9nT9JdUuWhH zw_&`_#~7}_a-E+I#1>}+iA#Y=Sum-<2ohIf3%6vyuE7|t&2wFo2gEjI54S-b2n3am z;DNv>;n9IWSeG3(V?R0&NXbB8kSZ)Z5i~4hsWt$~9t~f!4Ie0E31z`H%qPqc#u~;} z%&gB)%;dq&z);G>z)%nt$`-)n%gMmN&7i=L$DYTU$A;)4gtA*RGBD(Kzy>B#UJ$X$ zmLrtIgfW;C*%YKMC21?Vd<2XcgQ${d4u^vIiVdgE)rJo!iV%=VFl`t<#B|v1c8>L zB9bYXmB$gxn7<>G+lL|l49wL;t+VD2Xe{xO5KHhbIC3mO21*M~hq7Q@6VDMW3W_W6 zz6;V~OAH)a;-j%ei`a?=bqc|`DG1al1hWu?5@_d+D}oPW1;g`wutcz=B@>ofhzZ}0 zAS~)xg4|%HBj`MiU@1_lAv1?dkEWRcN;6nm7Qr&G(t=uPhKRI_%^kATc1I|#v`b0t zG@5qlTRX{t%9+vH2~_qDqP7HckO#;`s5p-!Se~NTQb4H}6sg|wqkIpiBEdKqj4eOL zATQLI0`c-YLRnDi?++;b3al%=L6i6tVAtAAO+dox+Vx1zB4Npdi_v-q)qR|4M#lgBt1{B+3Pr>TxH%~r$+nlxYn5zLGX z44RC;*z)pA6f*NP8LPxVGc^X9#R?D+@T?1HatgHn?iO!;K}ja)Y?s8G($r#2Cd52U z74r5)6V2ix@VpGD;j775g>RK{N@j6lQV#kmWALm7>MG+~jD|(f$uV7!8*yyCECV?W zvG3DVv-l-wQ?n-HE%uVk+|+!~K|X&$GdN781-DpJ^GZ^SZZR4ZLDW>);4vAxgpIlmu>|0P+frh3~HiUu#4`oBBnPzbjs6V5r zMxFVqA`VdGbAkxaN-^*$MMdDPp`fL9w>aV<5nWsaigu!>tTQ(%zYrxS9PxLpSA!8E+cVR(hZ z7=5A)Z6fT3zWH^1hfDenS0o%S>N_m7SYS9?^rF5)2k%7*$0z*q3u-U&n_TCBqDvem zS3qKL0SMzNSQx^-&S7#9l?xHS#$f`QO@SJ85rqd)ah=2D5{JnResS1rmgWN23mhPV zU-LQ#6kX!b1kYbVxe)QI9Gajh9;nzw1n(-6>dPRbAnK9H%N&}ZS*W_sx(ggC3xvVM zMShj*98h$LL#3k*&Vw+na;SjpgQ~cQ;DIEdiZ6pqgD6KPFLS6o;S;^iCwGZYZbs?+ z>Y3Fm>@Uk(9|*k22f7>M0*5d04Ri;ZT$VT3p?Hzc<~oPX1r8fD2i`&Rj`?MI;~jw)`RuQA z*k9nVM^pV^*hlXTe$ngvS{M1XE^z3tNCXoX`Sq`JK+z=*{R{kBa4v*#l|#Ry48pw5 zp??vn!ag{?2bk_w` z#YF@UB!RB@3dlr=zUv%n7g2a{;maIq-@PG+Z*kb);*kFC2+;}ID*>winrzav8TT=!YOw8RPSXa@ho%XF=0j7p*$x>g zr3r%OLsRwG4vSi(34-QBQw`aU@L8t`G9Tq+Of_LU%B2Zn8}p``a~xyv7Dy9hz9_($ zYRh&}P#nb8U`%6RyQt~Hm?p@4Rf{o=neD1JNa7k3V;URVHD>KJLFQ|EjA@)~*YrW+ z*V!1;c-XG9`+-b)D$bpz&G=M_F^!+)sWKagtqn5hsUDa#1d}FU(wsd_kog${i2aNi zOtOJVPB6&>CixlDv>BfXvZZOTKNDw6lj3%j3q1SE z9g&XIv*(G1Z>$KyI)@7LqcsBqLkeilRS)Jy9J8kISvFW|hj|%1=Zescbu$2>PY=@# z+PDzROOh)=XMG{;0ky|rPKMGrH;y25VRaqK3?VO4cw<)$k2QiS;S2T&szl8G@aeHa zXMFjC1(0TZK@kP^Cz4rMW^k1lLb1;H+A`*`+A`*`*)kTihcaTDPt4zqWmE|fa-i-S z*nR||#1Je9o=Fsn*n*VygsC2@@bMvdngh+GBix2Pp5VIt8H0t9LJhlW#BA=MOqIbR z&`~AcV9{VPq&a5d(*=$>=7K%?47q}#f+mc?;!+G*9HERk@KYl~S%RDq@eO0a$`0^~ z7Lv-(UCgX`GZBEqtS{$hqL`cS@#TWs0o5IVIYvm`a87-X!1LIY&%h{Gl)F*hkC(e9QvcoYdd zOjMK$UJ@Q(lv?dv0oRabkLE5vX5eP93X8x?&#Z6_8Hw z(GQ^DD#p4B-T@M`mSo3gkp&|I12ZTJpE5Bp{CLUG!0?1m;WD36gZmvW@jD#C*EwV^ zamdUF+Ms%o!{Ua3_(xV|Zng&J4_pia!u>U!HT`uxbqhpqK*rmqWM1Y_fE=Q5g+ud> zp!6Lc@#{Pamv|Hw2yIBc$YXPdN3z@h29NM(b`~M74z>?m4E#d@uI(bq=!&9A-B-`1<)f`LA$D-r-YT;Ch)4G;DBzL;nHlN_ZVp*t+=Z9Lg6t zl&^4T-q5p07sI<^94Gq?bAZMkdy3&a2;&-u;RIU<6QTmfyUJmBL0AnU zgKYL?knIp*Wb!J9A$-6?dq(aB)HCNm10K*5=pZM@fsULjhA^Q=&Ovz~iE6k6gmHyK zdqysV2~mv1g9~5g(0(8*-@$fA*sg=QhvNo^=yeY1iyYEdIAqaByQJi1h+W{2?Ff9CU@@NASgkhF~ek zzzV3uY_73E}qLAJRr6Rd$2T)5e_e&(bea$>JLlXlW6@{G4}ta<%VE9)uSrMP1Zs1mq+jsakbK~?A^9U*k$Q^)B*g$| z#0g;w_7Nurm_J}{1FuO(=)#(kLgBjn8G{7~hB$oPJ3*C*ex)FEynroOC|Ed@4cf2d zAR(sU>!Dyigv3-Hi!Eb8DfTh(U=hr{Dfr8mX!!U-uqezPVFnL<28KM&P$q1nx4~jC zby!M0tZ4`qGbv0A48Hmd3HCQ}JpDG{_gLLwp>DJ4phloE+43qbpf!MOkyJ}`fSM`#gl#F`?bF?9Pg21^DH&HMr% z0VO`a5LAg6AC!WQ(DDXL2g_g`i9>`n_A(I1NF3-;1kfmL!Rb&2tZVmKf*JK0DJl(R zM@vIml!io&w6Xea=U{MW^>YW4>flkgYhV&`D?%a%gP%ARN8E0)q@?EN7nOhp8&pAK z59*8z3@`ux|NpEJi}^6n#DTcg?PrA#hP5Tx41HkGxLf|67!N%i$HU1MMWU9 zp^M{;L8C!97RNyb+fw4oa*?h^0Iggr1Q~$3-mZ#IIkB`PU$?jFOZMX80Qsl_Fz-qci~d;|=1J}6}U8R#4coa^Xr@f0T)fmVtYmn0@<7ukS( z0*@0F8z%89K*8nAt!sUzm`;r1Ua} zE@+t>>i8I*lOiC)b{E9d*05a$Ey^*y$YFYg!yIGeM)tat)e5R(4D z#ULUL8(Y*tj4kS{FucH_v%>Hqzs_|ID7ws{10Gw1av|bZIdnjyiBPeNC_IRYYaBWg z%puI{96A>fysI2K;IT!JUSzW`gDi$?fHSUg=zx7-bdlfa0*CE}(hcb#D*YnA?R5?) zy2N37f!_$ug)pvi*n$p|g(|s-;9W&heVM~{Lpnq~GI^Q9_PZ^3unuYc6c+=h%qP(L zsZRn7yei*~!Say7yp`ZnBO;VRr$(5v?Prcq2CtHe-~_Fb3bz2Ql8WF2t&$4223>I> z9Kp$aM1wKhj_ru1bp$8#Q5D7rMz*7>9(EC&%;%IDBUm`jselxnv-Z%8;AFn8!X2T^ zcwL_{f}Q2M0UL;I!3Yw!29tIm@&+TAWC4@xj1kI=H#pfM6xeU@Ge!vU+z{Xav1QmJ zlo>TSia>)VRh*6m1<`)_d8x5QpmCL3%$a#5MW7LpTdZk0`H3Y(pd(0&)`H5_HK6Go zi(6dr@oA+crA4Xn@kO9Xom(79rI|S;nR&%UpfdZGWPCv-sN~aw)Y+N&d3r^u8lTHBq+& z3d&OB%W~t3Q%gzLp*{R^__?9TDVX5GXqPR!|6ggrb0(98r zEtcHGqU@qP5WfaQG=qq3AmS>BcmN_^f{5230(9#v_@W>1QQY9eh``IS!Lu<%ph~C+ zG`kL-Lo1R61q+8wZhlH>PO4o|7$XA%sCiQy$;80$ftit!@h*eXa|VVMhGxFo4BU4a z@I`BeTL|Z4AFNPEblVteqm!{$_o}%VPN8D zmu{54&M0z`QRFidFJltpXJ!T_(GOte2L&{F5k?o7Jc#*04^3VOArE4H&_a{9W8{Jw zZ^y{>!HEe?+Kf>aDs9Fn`=N*tOCEt;neG!2?}dmC+6?{gFwP5i)%X01_4Xng9R* literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_doctor.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_doctor.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae034ec1ada9454d25b8a3caf67d8e17b5dc689b GIT binary patch literal 10263 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 SRQ|#v%xLvhN0Lzw9F_oRjYt&$ literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_domain.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_domain.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99b4bfe06411e6a2ab92edabb43c07055bd982f5 GIT binary patch literal 9131 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 AFaQ7m literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_factory.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_factory.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1fb5b0c3e30ec6e86f7f4eec290c90eea2f23621 GIT binary patch literal 3385 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 g#yG|=N}P=1UnH~`6~06t<8 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_settings.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_settings.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c12438d5bcd3cf6bfad86b1d62ac590c3440e97 GIT binary patch literal 11190 zcmey&%ge>Uz`!88dTnL~I|IXG5C?{tpp4IH3=9lY8G;#t8NC^b7{N4?7jrR_0)rPz z5vu}3hOLM#m?4N> z!5qQtU>09GqbBD|kfnZ_jJJ4$Q%g!R^U{l5i;D7#G#PJkI~Ekc#F9a(V3>n}fq|KU zf#GwK0LY!uFmZ+;m=EA03=B#P!HmI7!OX!d!K}e-daS|h!5o%Mc?_WpK`cnhpv*kR zV9tESP$nx628MiN4X9|!E(V5ZHYkT72xLEu1ErN1g1JJOO&Ei@BZN$tOra(R^F)I~ z8>A?R2SlP`B?eQdfHeaHLy9ztEqqWZBua@Pl);n{A^>w+q8N%U0f;OziLA??F_;I* zC)iap!t5ZZk_o0Vm{*TkmyLlTk0qEdm_L-ofDz1NjTVNv1j#HBm>7aqVhCl0nFXU$ z8G{*Y8S@xz84HX;8G^(Rs$r}=#$W-8VoPu|wrCVv60kTz(0Po(?4a24AvI?SfpeDd zXk5`KuA~rg1Y_kf28)2=itKVwbTqbT6k9T|I6}~QjKN|Q#g_PJY|$vT!XDpBh*9L@#K;c}xf4hu^JJ!oQF4dEsjD~~x?o}w66 z7>#jQSR!b8$9Ot}rlMb!YDP&(L9vy-enC-wMQ(nDUTQ^RZb43}US?idVoqj?m4$(U znf@)7qWt`lTWrOt$wjFpx0nkObE`NL3o?!Lvx@WcG+A$P2L$;?`1(i0hX(oF;(>F5 zU4w23z<2?U!NKAFK`ys=VIo1Ujy}E!1&#rp@lKAxu5K$CZwch&C#J-wx8mBpuJ z=A>4s5aUu!#$QafwwjDp#;O_xl}4JyT-8v9f~^9GWuRHC2lZ-vX;BVWt%5B?!c?>P z7Dq93q(+nZ7IQ(R0f;aH5vEmqiN(dKMI~S}%r%Qag636R`iL-Ktg--^Pr%B=g3S1& z#Nt#Gdsi~vV#+MmWUR6x!i2KaqRh0)_>!Dru3Cl6Vz7fuHH%;JGcYh{GFI8+F`_87 zur#%}B)%jwH#NVsB)&K`IX^E2WDX(>syIL~?NE@IOTay;X=$m+C7ETZ@ukJ7MR|$2 z=zgk-1Ubb776m4n#n33g5k$p_X{jZZ@df!gnaP!0wR#0bsb#5oCGp8csfi^Z!=auv z*DPMiQY6H{z)%Dlg8{{aNs%Z61H&)mII@Jqk>yIps%XmGk(!s7l#?1?0t&g@{1jA| zRB1D&vz%GjmEZ^NMe=<>V(P<`mzOh%e5_FU?5-D~(Uj$xlkm0XyIpXFMp2At0x-A0ve7LemXh90F{AoOd){`(PL;(%*nvOpa286{PjbNQ;UlA3o_F6%kzt}iwhEy zQ}qjqGRqQ6^wTp-^ok7&^-@yHzzPb=QgzF6b&FF=N(=Nseklf%@vuTrub}dl5l9>^ z5)UbqQ_~Vlb4rTi6Z2By^FXnblUbD-4~vr`(2y@X0|P^GKMMmx1H&DDsq6g8m-v+* zNITt-QM#d^aYI(=hJyA-ZZ=M?4}1(#@*f2`1=t!qK8P{!h+OBEzsN0rfyMKJvL}SR z$nANZ1&S`QcwXR^hjSr}t1O-!77*rj7SD?a-c=;km%%2&RlymTSv+rW3t#6}xyY?@ zfyHD6516>fZE~FjiY~F3T;NuLb0LhYEG8g3p-L_ycvq2BUj~^6QIAYsW-$RbiY~F}U*J}Qb0LhYEczfTp-L_ycvq2BUj~^6QIAYsX3+;*seO@K z`vQyMiXt#^k=yV(3lv>qF}%R74d+4_S6K`}Rzj6rMDVU6slE&{527BKyv$+@3Urtvz92`pe0FLEnhV6nW;ZE%s>-~x-~23H8d zZF!vqiY~EOUf?!>b0LhYES4beLX})Z@UDVXD?$`q2C<bwjx z8zPKMUS+XHa);%0Zmo;lS{GO>S7d{Ui|FB@h3tvzESAKF2UO=}klAR4K+HjMhwF82 z(~I1u7g$_($bgB9+^*MIpy(2d>jiF8I2XdW%Hj$N52%uh2;NnYYDI{m%OEyX=Vg%D z5MgBU8jI^^PMWVS zGl~qs45rXIJq3nz21N!P2Kd+*BLhPklLCW0g8~Cc&X!r8A&pU!)$f)BtljMD=ji0) z8Xw{s91`#A@8WukrKG4d)vX9Lv!=;cAxnRxj=urbIff8u22JbQ}r6B z>MwBBHCw`lc@LK(7duLvfNBqrqdyC=5IYYF3te3EpZS78pqK{79D>MW3TDkW3T5_T z$ajHzZu!^bAsqCxF- zu%nU8!ZHtv+$Y8|HpyhmSm2C(zB^wq2-{2@Q!q0H@y#=T!~$jc zB7IPK%?`1~2qbC(B0z<1kp+ll1|lp$ZEaAI3GD_HflGE*)RG<9dU&Z0Q35K_bL)6kP_fp*k;v z%!UXflUG^HkQy8V*8sc5Vhyfqu~%+4DS}TeWzA6nkQ=IvgPN0(2}q9;NC?s*F#8jQ>EY4A^*C zT25kmd}eWcXa(}RoyX6EPV6{VIJWtODg;)Dy`Vuy-B zS{M$XmWOabS!#S)E@%+1v_LNvv<4N@CA=kyA_E)tgS1>ggLvR>9=HVnj?f|}P?ACP z9XV`r^HWN5QtgUD7#J8pt2c{5Ete0>jEs!;85mj^nt7ix=-y?}{lX@~=*IX(kDZb0 QiwGy9%~uH)Msu)_0oWB~ssI20 literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c370d73 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/integration/__pycache__/test_live_proxmox.cpython-313-pytest-8.4.2.pyc b/tests/integration/__pycache__/test_live_proxmox.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74f68adfb391857d60fdbf50c54edb6284bf16aa GIT binary patch literal 9560 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 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 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..dd7411b --- /dev/null +++ b/tests/test_app.py @@ -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] diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..3faf840 --- /dev/null +++ b/tests/test_doctor.py @@ -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 diff --git a/tests/test_domain.py b/tests/test_domain.py new file mode 100644 index 0000000..71ebb31 --- /dev/null +++ b/tests/test_domain.py @@ -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 diff --git a/tests/test_factory.py b/tests/test_factory.py new file mode 100644 index 0000000..e5fb38d --- /dev/null +++ b/tests/test_factory.py @@ -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() diff --git a/tests/test_proxmox_client.py b/tests/test_proxmox_client.py new file mode 100644 index 0000000..3965f85 --- /dev/null +++ b/tests/test_proxmox_client.py @@ -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 + ) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..98c5af3 --- /dev/null +++ b/tests/test_settings.py @@ -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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c940681 --- /dev/null +++ b/uv.lock @@ -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" }, +]