portacode 1.4.11.dev0__py3-none-any.whl → 1.4.11.dev5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +2 -2
- portacode/cli.py +39 -0
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +71 -2
- portacode/connection/handlers/__init__.py +8 -2
- portacode/connection/handlers/proxmox_infra.py +491 -8
- portacode/connection/handlers/system_handlers.py +48 -7
- portacode/connection/terminal.py +4 -0
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.11.dev5.dist-info}/METADATA +1 -1
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.11.dev5.dist-info}/RECORD +13 -13
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.11.dev5.dist-info}/WHEEL +0 -0
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.11.dev5.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.11.dev5.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.11.dev5.dist-info}/top_level.txt +0 -0
portacode/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '1.4.11.
|
|
32
|
-
__version_tuple__ = version_tuple = (1, 4, 11, '
|
|
31
|
+
__version__ = version = '1.4.11.dev5'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 4, 11, 'dev5')
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
portacode/cli.py
CHANGED
|
@@ -405,6 +405,45 @@ def send_control(message: str, gateway: str | None) -> None: # noqa: D401 – C
|
|
|
405
405
|
finally:
|
|
406
406
|
await mgr.stop()
|
|
407
407
|
|
|
408
|
+
asyncio.run(_run())
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@cli.command("revert_proxmox_infra")
|
|
412
|
+
@click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
|
|
413
|
+
def revert_proxmox_infra(gateway: str | None) -> None: # noqa: D401 – Click callback
|
|
414
|
+
"""Revert the Proxmox infrastructure configuration stored on this device."""
|
|
415
|
+
|
|
416
|
+
target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
|
|
417
|
+
|
|
418
|
+
async def _run() -> None:
|
|
419
|
+
keypair = get_or_create_keypair()
|
|
420
|
+
mgr = ConnectionManager(target_gateway, keypair, debug=False)
|
|
421
|
+
await mgr.start()
|
|
422
|
+
|
|
423
|
+
for _ in range(20):
|
|
424
|
+
if mgr.mux is not None:
|
|
425
|
+
break
|
|
426
|
+
await asyncio.sleep(0.1)
|
|
427
|
+
if mgr.mux is None:
|
|
428
|
+
click.echo("Failed to initialise connection – aborting.")
|
|
429
|
+
await mgr.stop()
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
ctl = mgr.mux.get_channel(0)
|
|
433
|
+
await ctl.send({"cmd": "revert_proxmox_infra"})
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
with click.progressbar(length=30, label="Waiting for revert response") as bar:
|
|
437
|
+
for _ in range(30):
|
|
438
|
+
try:
|
|
439
|
+
reply = await asyncio.wait_for(ctl.recv(), timeout=0.1)
|
|
440
|
+
click.echo(click.style("< " + json.dumps(reply, indent=2), fg="cyan"))
|
|
441
|
+
except asyncio.TimeoutError:
|
|
442
|
+
pass
|
|
443
|
+
bar.update(1)
|
|
444
|
+
finally:
|
|
445
|
+
await mgr.stop()
|
|
446
|
+
|
|
408
447
|
asyncio.run(_run())
|
|
409
448
|
|
|
410
449
|
|
|
@@ -322,7 +322,7 @@ This action does not require any payload fields.
|
|
|
322
322
|
|
|
323
323
|
### `setup_proxmox_infra`
|
|
324
324
|
|
|
325
|
-
Configures a Proxmox node for Portacode infrastructure usage (API token
|
|
325
|
+
Configures a Proxmox node for Portacode infrastructure usage (API token validation, automatic storage/template detection, bridge/NAT setup, and connectivity verification). Handled by [`ConfigureProxmoxInfraHandler`](./proxmox_infra.py).
|
|
326
326
|
|
|
327
327
|
**Payload Fields:**
|
|
328
328
|
|
|
@@ -333,7 +333,52 @@ Configures a Proxmox node for Portacode infrastructure usage (API token storage,
|
|
|
333
333
|
**Responses:**
|
|
334
334
|
|
|
335
335
|
* On success, the device will emit a [`proxmox_infra_configured`](#proxmox_infra_configured-event) event with the persisted infra snapshot.
|
|
336
|
-
* On failure, the device will emit an [`error`](#error) event with details (e.g., permission issues, missing proxmoxer/dnsmasq,
|
|
336
|
+
* On failure, the device will emit an [`error`](#error) event with details (e.g., permission issues, missing proxmoxer/dnsmasq, missing root privileges, or failed network verification).
|
|
337
|
+
|
|
338
|
+
### `revert_proxmox_infra`
|
|
339
|
+
|
|
340
|
+
Reverts the Proxmox infrastructure network changes and clears the stored API token. Handled by [`RevertProxmoxInfraHandler`](./proxmox_infra.py).
|
|
341
|
+
|
|
342
|
+
**Payload Fields:**
|
|
343
|
+
|
|
344
|
+
This action does not require any payload fields.
|
|
345
|
+
|
|
346
|
+
**Responses:**
|
|
347
|
+
|
|
348
|
+
* On success, the device will emit a [`proxmox_infra_reverted`](#proxmox_infra_reverted-event) event containing the cleared snapshot.
|
|
349
|
+
|
|
350
|
+
### `create_proxmox_container`
|
|
351
|
+
|
|
352
|
+
Creates a Portacode-managed LXC container, starts it, and bootstraps the Portacode service by running the commands from [`proxmox_management/setup_portacode.py`](../../proxmox_management/setup_portacode.py). Handled by [`CreateProxmoxContainerHandler`](./proxmox_infra.py).
|
|
353
|
+
|
|
354
|
+
**Payload Fields:**
|
|
355
|
+
|
|
356
|
+
* `template` (string, required): Template identifier to use for the CT (e.g., `local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst`).
|
|
357
|
+
* `disk_gib` (integer, optional): Rootfs size in GiB (defaults to 32).
|
|
358
|
+
* `ram_mib` (integer, optional): Memory size in MiB (defaults to 2048).
|
|
359
|
+
* `cpus` (integer, optional): Number of CPU cores (defaults to 1).
|
|
360
|
+
* `hostname` (string, optional): Desired hostname inside the container; defaults to `ct<vmid>`.
|
|
361
|
+
* `username` (string, optional): OS user to provision (defaults to `svcuser`).
|
|
362
|
+
* `password` (string, optional): Password for the user (used only during provisioning).
|
|
363
|
+
* `ssh_key` (string, optional): SSH public key to add to the user.
|
|
364
|
+
|
|
365
|
+
**Responses:**
|
|
366
|
+
|
|
367
|
+
* On success, the device will emit a [`proxmox_container_created`](#proxmox_container_created-event) event that includes the Portacode auth key produced inside the container.
|
|
368
|
+
* On failure, the device will emit an [`error`](#error) event.
|
|
369
|
+
|
|
370
|
+
### `proxmox_container_created`
|
|
371
|
+
|
|
372
|
+
Emitted after a successful `create_proxmox_container` action. Contains the new container ID, the Portacode public key produced inside the container, and the bootstrap logs.
|
|
373
|
+
|
|
374
|
+
**Event Fields:**
|
|
375
|
+
|
|
376
|
+
* `success` (boolean): True when the CT and Portacode bootstrap succeed.
|
|
377
|
+
* `message` (string): Human-readable summary (e.g., `Container 103 is ready`).
|
|
378
|
+
* `ctid` (string): The numeric CT ID.
|
|
379
|
+
* `public_key` (string): Portacode public auth key created inside the new container.
|
|
380
|
+
* `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
|
|
381
|
+
* `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
|
|
337
382
|
|
|
338
383
|
### `clock_sync_request`
|
|
339
384
|
|
|
@@ -993,6 +1038,7 @@ Provides system information in response to a `system_info` action. Handled by [`
|
|
|
993
1038
|
* `applied` (boolean): True when the bridge/NAT services were successfully configured.
|
|
994
1039
|
* `message` (string|null): Informational text about the network setup attempt.
|
|
995
1040
|
* `bridge` (string): The bridge interface configured (typically `vmbr1`).
|
|
1041
|
+
* `health` (string|null): `"healthy"` when the connectivity verification succeeded.
|
|
996
1042
|
* `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
|
|
997
1043
|
* `portacode_version` (string): Installed CLI version returned by `portacode.__version__`.
|
|
998
1044
|
|
|
@@ -1006,6 +1052,29 @@ Emitted after a successful `setup_proxmox_infra` action. The event reports the s
|
|
|
1006
1052
|
* `message` (string): User-facing summary (e.g., "Proxmox infrastructure configured").
|
|
1007
1053
|
* `infra` (object): Same snapshot described under [`system_info`](#system_info-event) `proxmox.infra`.
|
|
1008
1054
|
|
|
1055
|
+
### `proxmox_infra_reverted`
|
|
1056
|
+
|
|
1057
|
+
Emitted after a successful `revert_proxmox_infra` action. Indicates the infra config is no longer present and the network was restored.
|
|
1058
|
+
|
|
1059
|
+
**Event Fields:**
|
|
1060
|
+
|
|
1061
|
+
* `success` (boolean): True when the revert completed.
|
|
1062
|
+
* `message` (string): Summary (e.g., "Proxmox infrastructure configuration reverted").
|
|
1063
|
+
* `infra` (object): Snapshot with `configured=false` (matching [`system_info`](#system_info-event) `proxmox.infra`).
|
|
1064
|
+
|
|
1065
|
+
### `proxmox_container_created`
|
|
1066
|
+
|
|
1067
|
+
Emitted after a successful `create_proxmox_container` action to report the newly created CT, its Portacode public key, and the bootstrap logs.
|
|
1068
|
+
|
|
1069
|
+
**Event Fields:**
|
|
1070
|
+
|
|
1071
|
+
* `success` (boolean): True when the CT provisioning and `portacode connect` steps complete.
|
|
1072
|
+
* `message` (string): Human-readable summary (e.g., `Container 102 is ready`).
|
|
1073
|
+
* `ctid` (string): The container ID that was created.
|
|
1074
|
+
* `public_key` (string): Portacode public auth key discovered inside the container.
|
|
1075
|
+
* `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
|
|
1076
|
+
* `setup_steps` (array[object]): Detailed bootstrap step reports including stdout/stderr, elapsed time, and pass/fail status.
|
|
1077
|
+
|
|
1009
1078
|
### <a name="clock_sync_response"></a>`clock_sync_response`
|
|
1010
1079
|
|
|
1011
1080
|
Reply sent by the gateway immediately after receiving a `clock_sync_request`. Devices use this event plus the measured round-trip time to keep their local `ntp_clock` offset accurate.
|
|
@@ -14,6 +14,7 @@ from .terminal_handlers import (
|
|
|
14
14
|
TerminalListHandler,
|
|
15
15
|
)
|
|
16
16
|
from .system_handlers import SystemInfoHandler
|
|
17
|
+
from .update_handler import UpdatePortacodeHandler
|
|
17
18
|
from .file_handlers import (
|
|
18
19
|
FileReadHandler,
|
|
19
20
|
FileWriteHandler,
|
|
@@ -40,8 +41,11 @@ from .project_state_handlers import (
|
|
|
40
41
|
ProjectStateGitRevertHandler,
|
|
41
42
|
ProjectStateGitCommitHandler,
|
|
42
43
|
)
|
|
43
|
-
from .
|
|
44
|
-
|
|
44
|
+
from .proxmox_infra import (
|
|
45
|
+
ConfigureProxmoxInfraHandler,
|
|
46
|
+
CreateProxmoxContainerHandler,
|
|
47
|
+
RevertProxmoxInfraHandler,
|
|
48
|
+
)
|
|
45
49
|
|
|
46
50
|
__all__ = [
|
|
47
51
|
"BaseHandler",
|
|
@@ -54,6 +58,7 @@ __all__ = [
|
|
|
54
58
|
"TerminalListHandler",
|
|
55
59
|
"SystemInfoHandler",
|
|
56
60
|
"ConfigureProxmoxInfraHandler",
|
|
61
|
+
"CreateProxmoxContainerHandler",
|
|
57
62
|
# File operation handlers (optional - register as needed)
|
|
58
63
|
"FileReadHandler",
|
|
59
64
|
"FileWriteHandler",
|
|
@@ -80,4 +85,5 @@ __all__ = [
|
|
|
80
85
|
"ProjectStateGitRevertHandler",
|
|
81
86
|
"ProjectStateGitCommitHandler",
|
|
82
87
|
"UpdatePortacodeHandler",
|
|
88
|
+
"RevertProxmoxInfraHandler",
|
|
83
89
|
]
|
|
@@ -9,9 +9,10 @@ import shutil
|
|
|
9
9
|
import stat
|
|
10
10
|
import subprocess
|
|
11
11
|
import sys
|
|
12
|
+
import time
|
|
12
13
|
from datetime import datetime
|
|
13
14
|
from pathlib import Path
|
|
14
|
-
from typing import Any, Dict, Iterable, List, Tuple
|
|
15
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
15
16
|
|
|
16
17
|
import platformdirs
|
|
17
18
|
|
|
@@ -21,6 +22,10 @@ logger = logging.getLogger(__name__)
|
|
|
21
22
|
|
|
22
23
|
CONFIG_DIR = Path(platformdirs.user_config_dir("portacode"))
|
|
23
24
|
CONFIG_PATH = CONFIG_DIR / "proxmox_infra.json"
|
|
25
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
26
|
+
NET_SETUP_SCRIPT = REPO_ROOT / "proxmox_management" / "net_setup.py"
|
|
27
|
+
CONTAINERS_DIR = CONFIG_DIR / "containers"
|
|
28
|
+
MANAGED_MARKER = "portacode-managed:true"
|
|
24
29
|
|
|
25
30
|
DEFAULT_HOST = "localhost"
|
|
26
31
|
DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
|
|
@@ -214,10 +219,352 @@ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
|
|
|
214
219
|
return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
|
|
215
220
|
|
|
216
221
|
|
|
222
|
+
def _verify_connectivity(timeout: float = 5.0) -> bool:
|
|
223
|
+
try:
|
|
224
|
+
_call_subprocess(["/bin/ping", "-c", "2", "1.1.1.1"], check=True, timeout=timeout)
|
|
225
|
+
return True
|
|
226
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _revert_bridge() -> None:
|
|
231
|
+
try:
|
|
232
|
+
if NET_SETUP_SCRIPT.exists():
|
|
233
|
+
_call_subprocess([sys.executable, str(NET_SETUP_SCRIPT), "revert"], check=True)
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
logger.warning("Proxmox bridge revert failed: %s", exc)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _ensure_containers_dir() -> None:
|
|
239
|
+
CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
|
|
243
|
+
if storage_type in ("lvm", "lvmthin"):
|
|
244
|
+
return f"{storage}:{disk_gib}"
|
|
245
|
+
return f"{storage}:{disk_gib}G"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
|
|
249
|
+
for entry in storages:
|
|
250
|
+
if entry.get("storage") == storage_name:
|
|
251
|
+
return entry.get("type", "")
|
|
252
|
+
return ""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _validate_positive_int(value: Any, default: int) -> int:
|
|
256
|
+
try:
|
|
257
|
+
candidate = int(value)
|
|
258
|
+
if candidate > 0:
|
|
259
|
+
return candidate
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
return default
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
|
|
266
|
+
start = time.time()
|
|
267
|
+
while True:
|
|
268
|
+
status = proxmox.nodes(node).tasks(upid).status.get()
|
|
269
|
+
if status.get("status") == "stopped":
|
|
270
|
+
return status, time.time() - start
|
|
271
|
+
time.sleep(1)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _list_running_managed(proxmox: Any, node: str) -> List[Tuple[str, Dict[str, Any]]]:
|
|
275
|
+
entries = []
|
|
276
|
+
for ct in proxmox.nodes(node).lxc.get():
|
|
277
|
+
if ct.get("status") != "running":
|
|
278
|
+
continue
|
|
279
|
+
vmid = str(ct.get("vmid"))
|
|
280
|
+
cfg = proxmox.nodes(node).lxc(vmid).config.get()
|
|
281
|
+
if cfg and MANAGED_MARKER in (cfg.get("description") or ""):
|
|
282
|
+
entries.append((vmid, cfg))
|
|
283
|
+
return entries
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
|
|
287
|
+
status = proxmox.nodes(node).lxc(vmid).status.current.get()
|
|
288
|
+
if status.get("status") == "running":
|
|
289
|
+
uptime = status.get("uptime", 0)
|
|
290
|
+
logger.info("Container %s already running (%ss)", vmid, uptime)
|
|
291
|
+
return status, 0.0
|
|
292
|
+
|
|
293
|
+
node_status = proxmox.nodes(node).status.get()
|
|
294
|
+
mem_total_mb = int(node_status.get("memory", {}).get("total", 0) // (1024**2))
|
|
295
|
+
cores_total = int(node_status.get("cpuinfo", {}).get("cores", 0))
|
|
296
|
+
|
|
297
|
+
running = _list_running_managed(proxmox, node)
|
|
298
|
+
used_mem_mb = sum(int(cfg.get("memory", 0)) for _, cfg in running)
|
|
299
|
+
used_cores = sum(int(cfg.get("cores", 0)) for _, cfg in running)
|
|
300
|
+
|
|
301
|
+
target_cfg = proxmox.nodes(node).lxc(vmid).config.get()
|
|
302
|
+
target_mem_mb = int(target_cfg.get("memory", 0))
|
|
303
|
+
target_cores = int(target_cfg.get("cores", 0))
|
|
304
|
+
|
|
305
|
+
if mem_total_mb and used_mem_mb + target_mem_mb > mem_total_mb:
|
|
306
|
+
raise RuntimeError("Not enough RAM to start this container safely.")
|
|
307
|
+
if cores_total and used_cores + target_cores > cores_total:
|
|
308
|
+
raise RuntimeError("Not enough CPU cores to start this container safely.")
|
|
309
|
+
|
|
310
|
+
upid = proxmox.nodes(node).lxc(vmid).status.start.post()
|
|
311
|
+
return _wait_for_task(proxmox, node, upid)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
|
|
315
|
+
_ensure_containers_dir()
|
|
316
|
+
path = CONTAINERS_DIR / f"ct-{vmid}.json"
|
|
317
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
|
|
321
|
+
templates = config.get("templates") or []
|
|
322
|
+
default_template = templates[0] if templates else ""
|
|
323
|
+
template = message.get("template") or default_template
|
|
324
|
+
if not template:
|
|
325
|
+
raise ValueError("Container template is required.")
|
|
326
|
+
|
|
327
|
+
bridge = config.get("network", {}).get("bridge", DEFAULT_BRIDGE)
|
|
328
|
+
hostname = (message.get("hostname") or "").strip()
|
|
329
|
+
disk_gib = _validate_positive_int(message.get("disk_gib") or message.get("disk"), 32)
|
|
330
|
+
ram_mib = _validate_positive_int(message.get("ram_mib") or message.get("ram"), 2048)
|
|
331
|
+
cpus = _validate_positive_int(message.get("cpus"), 1)
|
|
332
|
+
storage = message.get("storage") or config.get("default_storage") or ""
|
|
333
|
+
if not storage:
|
|
334
|
+
raise ValueError("Storage pool could not be determined.")
|
|
335
|
+
|
|
336
|
+
user = (message.get("username") or "svcuser").strip() or "svcuser"
|
|
337
|
+
password = message.get("password") or ""
|
|
338
|
+
ssh_key = (message.get("ssh_key") or "").strip()
|
|
339
|
+
|
|
340
|
+
payload = {
|
|
341
|
+
"template": template,
|
|
342
|
+
"storage": storage,
|
|
343
|
+
"disk_gib": disk_gib,
|
|
344
|
+
"ram_mib": ram_mib,
|
|
345
|
+
"cpus": cpus,
|
|
346
|
+
"hostname": hostname,
|
|
347
|
+
"net0": f"name=eth0,bridge={bridge},ip=dhcp",
|
|
348
|
+
"unprivileged": 1,
|
|
349
|
+
"swap_mb": 0,
|
|
350
|
+
"username": user,
|
|
351
|
+
"password": password,
|
|
352
|
+
"ssh_public_key": ssh_key,
|
|
353
|
+
"description": MANAGED_MARKER,
|
|
354
|
+
}
|
|
355
|
+
return payload
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _connect_proxmox(config: Dict[str, Any]) -> Any:
|
|
359
|
+
ProxmoxAPI = _ensure_proxmoxer()
|
|
360
|
+
return ProxmoxAPI(
|
|
361
|
+
config.get("host", DEFAULT_HOST),
|
|
362
|
+
user=config.get("user"),
|
|
363
|
+
token_name=config.get("token_name"),
|
|
364
|
+
token_value=config.get("token_value"),
|
|
365
|
+
verify_ssl=config.get("verify_ssl", False),
|
|
366
|
+
timeout=60,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _run_pct(vmid: int, cmd: str) -> Dict[str, Any]:
|
|
371
|
+
full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
|
|
372
|
+
start = time.time()
|
|
373
|
+
proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
374
|
+
return {
|
|
375
|
+
"cmd": cmd,
|
|
376
|
+
"returncode": proc.returncode,
|
|
377
|
+
"stdout": proc.stdout.strip(),
|
|
378
|
+
"stderr": proc.stderr.strip(),
|
|
379
|
+
"elapsed_s": round(time.time() - start, 2),
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
|
|
384
|
+
res = _run_pct(vmid, cmd)
|
|
385
|
+
if res["returncode"] != 0:
|
|
386
|
+
raise RuntimeError(res.get("stderr") or res.get("stdout") or "command failed")
|
|
387
|
+
return res
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
|
|
391
|
+
cmd = ["pct", "exec", str(vmid), "--", "bash", "-lc", f"su - {user} -c 'portacode connect'"]
|
|
392
|
+
proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
393
|
+
start = time.time()
|
|
394
|
+
|
|
395
|
+
data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
|
|
396
|
+
data_dir = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
|
|
397
|
+
key_dir = f"{data_dir}/portacode/keys"
|
|
398
|
+
pub_path = f"{key_dir}/id_portacode.pub"
|
|
399
|
+
priv_path = f"{key_dir}/id_portacode"
|
|
400
|
+
|
|
401
|
+
def file_size(path: str) -> Optional[int]:
|
|
402
|
+
stat_cmd = f"su - {user} -c 'test -s {path} && stat -c %s {path}'"
|
|
403
|
+
res = _run_pct(vmid, stat_cmd)
|
|
404
|
+
if res["returncode"] != 0:
|
|
405
|
+
return None
|
|
406
|
+
try:
|
|
407
|
+
return int(res["stdout"].strip())
|
|
408
|
+
except ValueError:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
last_pub = last_priv = None
|
|
412
|
+
stable = 0
|
|
413
|
+
while time.time() - start < timeout_s:
|
|
414
|
+
if proc.poll() is not None:
|
|
415
|
+
out, err = proc.communicate(timeout=1)
|
|
416
|
+
return {
|
|
417
|
+
"ok": False,
|
|
418
|
+
"error": "portacode connect exited before keys were created",
|
|
419
|
+
"stdout": (out or "").strip(),
|
|
420
|
+
"stderr": (err or "").strip(),
|
|
421
|
+
}
|
|
422
|
+
pub_size = file_size(pub_path)
|
|
423
|
+
priv_size = file_size(priv_path)
|
|
424
|
+
if pub_size and priv_size:
|
|
425
|
+
if pub_size == last_pub and priv_size == last_priv:
|
|
426
|
+
stable += 1
|
|
427
|
+
else:
|
|
428
|
+
stable = 0
|
|
429
|
+
last_pub, last_priv = pub_size, priv_size
|
|
430
|
+
if stable >= 1:
|
|
431
|
+
break
|
|
432
|
+
time.sleep(1)
|
|
433
|
+
|
|
434
|
+
if stable < 1:
|
|
435
|
+
proc.terminate()
|
|
436
|
+
try:
|
|
437
|
+
proc.wait(timeout=3)
|
|
438
|
+
except subprocess.TimeoutExpired:
|
|
439
|
+
proc.kill()
|
|
440
|
+
out, err = proc.communicate(timeout=1)
|
|
441
|
+
return {
|
|
442
|
+
"ok": False,
|
|
443
|
+
"error": "timed out waiting for portacode key files",
|
|
444
|
+
"stdout": (out or "").strip(),
|
|
445
|
+
"stderr": (err or "").strip(),
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
proc.terminate()
|
|
449
|
+
try:
|
|
450
|
+
proc.wait(timeout=3)
|
|
451
|
+
except subprocess.TimeoutExpired:
|
|
452
|
+
proc.kill()
|
|
453
|
+
|
|
454
|
+
key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
|
|
455
|
+
return {
|
|
456
|
+
"ok": True,
|
|
457
|
+
"public_key": key_res["stdout"].strip(),
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _summarize_error(res: Dict[str, Any]) -> str:
|
|
462
|
+
text = f"{res.get('stdout','')}\n{res.get('stderr','')}"
|
|
463
|
+
if "No space left on device" in text:
|
|
464
|
+
return "Disk full inside container; increase rootfs or clean apt cache."
|
|
465
|
+
if "Unable to acquire the dpkg frontend lock" in text or "lock-frontend" in text:
|
|
466
|
+
return "Another apt/dpkg process is running; retry after it finishes."
|
|
467
|
+
if "Temporary failure resolving" in text or "Could not resolve" in text:
|
|
468
|
+
return "DNS/network resolution failed inside container."
|
|
469
|
+
if "Failed to fetch" in text:
|
|
470
|
+
return "Package repo fetch failed; check network and apt sources."
|
|
471
|
+
return "Command failed; see stdout/stderr for details."
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _run_setup_steps(vmid: int, steps: List[Dict[str, Any]], user: str) -> Tuple[List[Dict[str, Any]], bool]:
|
|
475
|
+
results: List[Dict[str, Any]] = []
|
|
476
|
+
for step in steps:
|
|
477
|
+
if step.get("type") == "portacode_connect":
|
|
478
|
+
res = _portacode_connect_and_read_key(vmid, user, timeout_s=step.get("timeout_s", 10))
|
|
479
|
+
res["name"] = step["name"]
|
|
480
|
+
results.append(res)
|
|
481
|
+
if not res.get("ok"):
|
|
482
|
+
return results, False
|
|
483
|
+
continue
|
|
484
|
+
|
|
485
|
+
attempts = 0
|
|
486
|
+
while True:
|
|
487
|
+
attempts += 1
|
|
488
|
+
res = _run_pct(vmid, step["cmd"])
|
|
489
|
+
res["name"] = step["name"]
|
|
490
|
+
res["attempt"] = attempts
|
|
491
|
+
if res["returncode"] != 0:
|
|
492
|
+
res["error_summary"] = _summarize_error(res)
|
|
493
|
+
results.append(res)
|
|
494
|
+
if res["returncode"] == 0:
|
|
495
|
+
break
|
|
496
|
+
retry_on = step.get("retry_on", [])
|
|
497
|
+
if attempts >= step.get("retries", 0) + 1:
|
|
498
|
+
return results, False
|
|
499
|
+
if any(tok in (res.get("stderr", "") + res.get("stdout", "")) for tok in retry_on):
|
|
500
|
+
time.sleep(step.get("retry_delay_s", 3))
|
|
501
|
+
continue
|
|
502
|
+
return results, False
|
|
503
|
+
return results, True
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _bootstrap_portacode(vmid: int, user: str, password: str, ssh_key: str) -> Tuple[str, List[Dict[str, Any]]]:
|
|
507
|
+
steps = [
|
|
508
|
+
{
|
|
509
|
+
"name": "apt_update",
|
|
510
|
+
"cmd": "apt-get update -y",
|
|
511
|
+
"retries": 4,
|
|
512
|
+
"retry_delay_s": 5,
|
|
513
|
+
"retry_on": [
|
|
514
|
+
"Temporary failure resolving",
|
|
515
|
+
"Could not resolve",
|
|
516
|
+
"Failed to fetch",
|
|
517
|
+
],
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
"name": "install_deps",
|
|
521
|
+
"cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
|
|
522
|
+
"retries": 5,
|
|
523
|
+
"retry_delay_s": 5,
|
|
524
|
+
"retry_on": [
|
|
525
|
+
"lock-frontend",
|
|
526
|
+
"Unable to acquire the dpkg frontend lock",
|
|
527
|
+
"Temporary failure resolving",
|
|
528
|
+
"Could not resolve",
|
|
529
|
+
"Failed to fetch",
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
{"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
|
|
533
|
+
{"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
|
|
534
|
+
]
|
|
535
|
+
if password:
|
|
536
|
+
steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
|
|
537
|
+
if ssh_key:
|
|
538
|
+
steps.append({
|
|
539
|
+
"name": "add_ssh_key",
|
|
540
|
+
"cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
|
|
541
|
+
"retries": 0,
|
|
542
|
+
})
|
|
543
|
+
steps.extend([
|
|
544
|
+
{"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
|
|
545
|
+
{"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
|
|
546
|
+
{"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30},
|
|
547
|
+
])
|
|
548
|
+
|
|
549
|
+
results, ok = _run_setup_steps(vmid, steps, user)
|
|
550
|
+
if not ok:
|
|
551
|
+
raise RuntimeError("Portacode bootstrap steps failed.")
|
|
552
|
+
key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
|
|
553
|
+
public_key = key_step.get("public_key") if key_step else None
|
|
554
|
+
if not public_key:
|
|
555
|
+
raise RuntimeError("Portacode connect did not return a public key.")
|
|
556
|
+
return public_key, results
|
|
557
|
+
|
|
558
|
+
|
|
217
559
|
def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
218
|
-
if not config:
|
|
219
|
-
return {"configured": False}
|
|
220
560
|
network = config.get("network", {})
|
|
561
|
+
base_network = {
|
|
562
|
+
"applied": network.get("applied", False),
|
|
563
|
+
"message": network.get("message"),
|
|
564
|
+
"bridge": network.get("bridge", DEFAULT_BRIDGE),
|
|
565
|
+
}
|
|
566
|
+
if not config:
|
|
567
|
+
return {"configured": False, "network": base_network}
|
|
221
568
|
return {
|
|
222
569
|
"configured": True,
|
|
223
570
|
"host": config.get("host"),
|
|
@@ -227,11 +574,7 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
227
574
|
"default_storage": config.get("default_storage"),
|
|
228
575
|
"templates": config.get("templates") or [],
|
|
229
576
|
"last_verified": config.get("last_verified"),
|
|
230
|
-
"network":
|
|
231
|
-
"applied": network.get("applied", False),
|
|
232
|
-
"message": network.get("message"),
|
|
233
|
-
"bridge": network.get("bridge", DEFAULT_BRIDGE),
|
|
234
|
-
},
|
|
577
|
+
"network": base_network,
|
|
235
578
|
}
|
|
236
579
|
|
|
237
580
|
|
|
@@ -254,6 +597,13 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
|
|
|
254
597
|
network: Dict[str, Any] = {}
|
|
255
598
|
try:
|
|
256
599
|
network = _ensure_bridge()
|
|
600
|
+
# Wait for network convergence before validating connectivity
|
|
601
|
+
time.sleep(2)
|
|
602
|
+
if _verify_connectivity():
|
|
603
|
+
network["health"] = "healthy"
|
|
604
|
+
else:
|
|
605
|
+
network = {"applied": False, "bridge": DEFAULT_BRIDGE, "message": "Connectivity check failed; bridge reverted"}
|
|
606
|
+
_revert_bridge()
|
|
257
607
|
except PermissionError as exc:
|
|
258
608
|
network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
|
|
259
609
|
logger.warning("Bridge setup skipped: %s", exc)
|
|
@@ -287,6 +637,124 @@ def get_infra_snapshot() -> Dict[str, Any]:
|
|
|
287
637
|
return snapshot
|
|
288
638
|
|
|
289
639
|
|
|
640
|
+
def revert_infrastructure() -> Dict[str, Any]:
|
|
641
|
+
_revert_bridge()
|
|
642
|
+
if CONFIG_PATH.exists():
|
|
643
|
+
CONFIG_PATH.unlink()
|
|
644
|
+
snapshot = build_snapshot({})
|
|
645
|
+
snapshot["network"] = snapshot.get("network", {})
|
|
646
|
+
snapshot["network"]["applied"] = False
|
|
647
|
+
snapshot["network"]["message"] = "Reverted to previous network state"
|
|
648
|
+
snapshot["network"]["bridge"] = DEFAULT_BRIDGE
|
|
649
|
+
return snapshot
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _allocate_vmid(proxmox: Any) -> int:
|
|
653
|
+
return int(proxmox.cluster.nextid.get())
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) -> Tuple[int, float]:
|
|
657
|
+
from proxmoxer.core import ResourceException
|
|
658
|
+
|
|
659
|
+
storage_type = _get_storage_type(proxmox.nodes(node).storage.get(), payload["storage"])
|
|
660
|
+
rootfs = _format_rootfs(payload["storage"], payload["disk_gib"], storage_type)
|
|
661
|
+
vmid = _allocate_vmid(proxmox)
|
|
662
|
+
if not payload.get("hostname"):
|
|
663
|
+
payload["hostname"] = f"ct{vmid}"
|
|
664
|
+
try:
|
|
665
|
+
upid = proxmox.nodes(node).lxc.create(
|
|
666
|
+
vmid=vmid,
|
|
667
|
+
hostname=payload["hostname"],
|
|
668
|
+
ostemplate=payload["template"],
|
|
669
|
+
rootfs=rootfs,
|
|
670
|
+
memory=int(payload["ram_mib"]),
|
|
671
|
+
swap=int(payload.get("swap_mb", 0)),
|
|
672
|
+
cores=int(payload.get("cpus", 1)),
|
|
673
|
+
cpuunits=int(payload.get("cpuunits", 256)),
|
|
674
|
+
net0=payload["net0"],
|
|
675
|
+
unprivileged=int(payload.get("unprivileged", 1)),
|
|
676
|
+
description=payload.get("description", MANAGED_MARKER),
|
|
677
|
+
password=payload.get("password") or None,
|
|
678
|
+
ssh_public_keys=payload.get("ssh_public_key") or None,
|
|
679
|
+
)
|
|
680
|
+
status, elapsed = _wait_for_task(proxmox, node, upid)
|
|
681
|
+
return vmid, elapsed
|
|
682
|
+
except ResourceException as exc:
|
|
683
|
+
raise RuntimeError(f"Failed to create container: {exc}") from exc
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class CreateProxmoxContainerHandler(SyncHandler):
|
|
687
|
+
"""Provision a new managed LXC container via the Proxmox API."""
|
|
688
|
+
|
|
689
|
+
@property
|
|
690
|
+
def command_name(self) -> str:
|
|
691
|
+
return "create_proxmox_container"
|
|
692
|
+
|
|
693
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
694
|
+
logger.info("create_proxmox_container command received")
|
|
695
|
+
if os.geteuid() != 0:
|
|
696
|
+
logger.error("container creation rejected: not running as root")
|
|
697
|
+
raise PermissionError("Container creation requires root privileges.")
|
|
698
|
+
|
|
699
|
+
config = _load_config()
|
|
700
|
+
if not config or not config.get("token_value"):
|
|
701
|
+
logger.error("container creation rejected: infra not configured")
|
|
702
|
+
raise ValueError("Proxmox infrastructure is not configured.")
|
|
703
|
+
if not config.get("network", {}).get("applied"):
|
|
704
|
+
logger.error("container creation rejected: network bridge not applied")
|
|
705
|
+
raise RuntimeError("Proxmox bridge setup must be applied before creating containers.")
|
|
706
|
+
|
|
707
|
+
proxmox = _connect_proxmox(config)
|
|
708
|
+
node = config.get("node") or DEFAULT_NODE_NAME
|
|
709
|
+
payload = _build_container_payload(message, config)
|
|
710
|
+
payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
|
|
711
|
+
payload["memory"] = int(payload["ram_mib"])
|
|
712
|
+
payload["node"] = node
|
|
713
|
+
logger.debug(
|
|
714
|
+
"Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
|
|
715
|
+
node,
|
|
716
|
+
payload["template"],
|
|
717
|
+
payload["ram_mib"],
|
|
718
|
+
payload["cpus"],
|
|
719
|
+
payload["storage"],
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
vmid, _ = _instantiate_container(proxmox, node, payload)
|
|
724
|
+
except Exception as exc:
|
|
725
|
+
logger.exception("container instantiation failed")
|
|
726
|
+
raise
|
|
727
|
+
|
|
728
|
+
payload["vmid"] = vmid
|
|
729
|
+
payload["created_at"] = datetime.utcnow().isoformat() + "Z"
|
|
730
|
+
_write_container_record(vmid, payload)
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
_start_container(proxmox, node, vmid)
|
|
734
|
+
public_key, steps = _bootstrap_portacode(vmid, payload["username"], payload["password"], payload["ssh_public_key"])
|
|
735
|
+
except Exception:
|
|
736
|
+
logger.exception("failed to start/setup container %s", vmid)
|
|
737
|
+
raise
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
"event": "proxmox_container_created",
|
|
741
|
+
"success": True,
|
|
742
|
+
"message": f"Container {vmid} is ready and Portacode key captured.",
|
|
743
|
+
"ctid": str(vmid),
|
|
744
|
+
"public_key": public_key,
|
|
745
|
+
"container": {
|
|
746
|
+
"vmid": vmid,
|
|
747
|
+
"hostname": payload["hostname"],
|
|
748
|
+
"template": payload["template"],
|
|
749
|
+
"storage": payload["storage"],
|
|
750
|
+
"disk_gib": payload["disk_gib"],
|
|
751
|
+
"ram_mib": payload["ram_mib"],
|
|
752
|
+
"cpus": payload["cpus"],
|
|
753
|
+
},
|
|
754
|
+
"setup_steps": steps,
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
|
|
290
758
|
class ConfigureProxmoxInfraHandler(SyncHandler):
|
|
291
759
|
@property
|
|
292
760
|
def command_name(self) -> str:
|
|
@@ -305,3 +773,18 @@ class ConfigureProxmoxInfraHandler(SyncHandler):
|
|
|
305
773
|
"message": "Proxmox infrastructure configured",
|
|
306
774
|
"infra": snapshot,
|
|
307
775
|
}
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
class RevertProxmoxInfraHandler(SyncHandler):
|
|
779
|
+
@property
|
|
780
|
+
def command_name(self) -> str:
|
|
781
|
+
return "revert_proxmox_infra"
|
|
782
|
+
|
|
783
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
784
|
+
snapshot = revert_infrastructure()
|
|
785
|
+
return {
|
|
786
|
+
"event": "proxmox_infra_reverted",
|
|
787
|
+
"success": True,
|
|
788
|
+
"message": "Proxmox infrastructure configuration reverted",
|
|
789
|
+
"infra": snapshot,
|
|
790
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""System command handlers."""
|
|
2
|
+
from __future__ import annotations
|
|
2
3
|
|
|
3
4
|
import concurrent.futures
|
|
4
5
|
import getpass
|
|
@@ -7,6 +8,7 @@ import logging
|
|
|
7
8
|
import os
|
|
8
9
|
import platform
|
|
9
10
|
import shutil
|
|
11
|
+
import subprocess
|
|
10
12
|
import threading
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
from typing import Any, Dict
|
|
@@ -128,18 +130,57 @@ def _get_playwright_info() -> Dict[str, Any]:
|
|
|
128
130
|
return result
|
|
129
131
|
|
|
130
132
|
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
|
|
133
|
+
def _run_probe_command(cmd: list[str]) -> str | None:
|
|
134
|
+
try:
|
|
135
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=3)
|
|
136
|
+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
137
|
+
return None
|
|
138
|
+
return result.stdout.strip()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_pveversion(output: str) -> str | None:
|
|
142
|
+
first_token = output.split(None, 1)[0] if output else ""
|
|
143
|
+
if not first_token:
|
|
144
|
+
return None
|
|
145
|
+
if "/" in first_token:
|
|
146
|
+
return first_token.split("/", 1)[1]
|
|
147
|
+
return first_token
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_dpkg_version(output: str) -> str | None:
|
|
151
|
+
for line in output.splitlines():
|
|
152
|
+
if line.lower().startswith("version:"):
|
|
153
|
+
return line.split(":", 1)[1].strip()
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _get_proxmox_version() -> str | None:
|
|
134
158
|
release_file = Path("/etc/proxmox-release")
|
|
135
159
|
if release_file.exists():
|
|
136
|
-
info["is_proxmox_node"] = True
|
|
137
160
|
try:
|
|
138
|
-
|
|
161
|
+
return release_file.read_text().strip()
|
|
139
162
|
except Exception:
|
|
140
|
-
|
|
141
|
-
|
|
163
|
+
pass
|
|
164
|
+
value = _run_probe_command(["pveversion"])
|
|
165
|
+
parsed = _parse_pveversion(value or "")
|
|
166
|
+
if parsed:
|
|
167
|
+
return parsed
|
|
168
|
+
for pkg in ("pve-manager", "proxmox-ve"):
|
|
169
|
+
pkg_output = _run_probe_command(["dpkg", "-s", pkg])
|
|
170
|
+
parsed = _parse_dpkg_version(pkg_output or "")
|
|
171
|
+
if parsed:
|
|
172
|
+
return parsed
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _get_proxmox_info() -> Dict[str, Any]:
|
|
177
|
+
"""Detect if the current host is a Proxmox node."""
|
|
178
|
+
info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
|
|
179
|
+
if Path("/etc/proxmox-release").exists() or Path("/etc/pve").exists():
|
|
142
180
|
info["is_proxmox_node"] = True
|
|
181
|
+
version = _get_proxmox_version()
|
|
182
|
+
if version:
|
|
183
|
+
info["version"] = version
|
|
143
184
|
info["infra"] = get_infra_snapshot()
|
|
144
185
|
return info
|
|
145
186
|
|
portacode/connection/terminal.py
CHANGED
|
@@ -53,6 +53,8 @@ from .handlers import (
|
|
|
53
53
|
ProjectStateGitCommitHandler,
|
|
54
54
|
UpdatePortacodeHandler,
|
|
55
55
|
ConfigureProxmoxInfraHandler,
|
|
56
|
+
CreateProxmoxContainerHandler,
|
|
57
|
+
RevertProxmoxInfraHandler,
|
|
56
58
|
)
|
|
57
59
|
from .handlers.project_aware_file_handlers import (
|
|
58
60
|
ProjectAwareFileWriteHandler,
|
|
@@ -474,6 +476,8 @@ class TerminalManager:
|
|
|
474
476
|
self._command_registry.register(ProjectStateGitCommitHandler)
|
|
475
477
|
# System management handlers
|
|
476
478
|
self._command_registry.register(ConfigureProxmoxInfraHandler)
|
|
479
|
+
self._command_registry.register(CreateProxmoxContainerHandler)
|
|
480
|
+
self._command_registry.register(RevertProxmoxInfraHandler)
|
|
477
481
|
self._command_registry.register(UpdatePortacodeHandler)
|
|
478
482
|
|
|
479
483
|
# ---------------------------------------------------------------------
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
|
|
2
2
|
portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
|
|
3
3
|
portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
|
|
4
|
-
portacode/_version.py,sha256=
|
|
5
|
-
portacode/cli.py,sha256=
|
|
4
|
+
portacode/_version.py,sha256=etzGixK9D-N8iE-N-EQixfapn6X63fGLccK__6i42Js,719
|
|
5
|
+
portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
|
|
6
6
|
portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
|
|
7
7
|
portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
|
|
8
8
|
portacode/logging_categories.py,sha256=9m-BYrjyHh1vjZYBQT4JhAh6b_oYUhIWayO-noH1cSE,5063
|
|
@@ -12,20 +12,20 @@ portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJ
|
|
|
12
12
|
portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
|
|
13
13
|
portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
|
|
14
14
|
portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
|
|
15
|
-
portacode/connection/terminal.py,sha256=
|
|
15
|
+
portacode/connection/terminal.py,sha256=NFkbbJe4Rz5XUi1WYEf9kXJqH8ETqqC9tcQhVORG3Y8,44599
|
|
16
16
|
portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
|
|
17
|
-
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=
|
|
18
|
-
portacode/connection/handlers/__init__.py,sha256=
|
|
17
|
+
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=1cYtf0iJBtY_Q4dadmKSdcHd67zg1f2mf-Gnm4-oI08,90511
|
|
18
|
+
portacode/connection/handlers/__init__.py,sha256=iiyF3smwiI0IeDYzWQTl2PPVfW6aSp-g2CSO1ZTo9Ho,2641
|
|
19
19
|
portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
|
|
20
20
|
portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
|
|
21
21
|
portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZbQw6sF04M3dM6rUV8Q,24477
|
|
22
22
|
portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
|
|
23
23
|
portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
|
|
24
24
|
portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
|
|
25
|
-
portacode/connection/handlers/proxmox_infra.py,sha256=
|
|
25
|
+
portacode/connection/handlers/proxmox_infra.py,sha256=P9XjBuHkqRsJYK9Y_Ei7kTyK__cwkf2u-W5-3uVB_cw,29286
|
|
26
26
|
portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
|
|
27
27
|
portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
|
|
28
|
-
portacode/connection/handlers/system_handlers.py,sha256=
|
|
28
|
+
portacode/connection/handlers/system_handlers.py,sha256=AKh7IbwptlLYrbSw5f-DHigvlaKHsg9lDP-lkAUm8cE,10755
|
|
29
29
|
portacode/connection/handlers/tab_factory.py,sha256=yn93h6GASjD1VpvW1oqpax3EpoT0r7r97zFXxML1wdA,16173
|
|
30
30
|
portacode/connection/handlers/terminal_handlers.py,sha256=HRwHW1GiqG1NtHVEqXHKaYkFfQEzCDDH6YIlHcb4XD8,11866
|
|
31
31
|
portacode/connection/handlers/update_handler.py,sha256=f2K4LmG4sHJZ3LahzzoRtHBULTKkPUNwuyhwuAAg3RA,2054
|
|
@@ -64,7 +64,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
|
|
|
64
64
|
portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
|
|
65
65
|
portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
|
|
66
66
|
portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
|
|
67
|
-
portacode-1.4.11.
|
|
67
|
+
portacode-1.4.11.dev5.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
|
|
68
68
|
test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
|
|
69
69
|
test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
|
|
70
70
|
test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
|
|
@@ -90,8 +90,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
|
|
|
90
90
|
testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
|
|
91
91
|
testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
|
|
92
92
|
testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
|
|
93
|
-
portacode-1.4.11.
|
|
94
|
-
portacode-1.4.11.
|
|
95
|
-
portacode-1.4.11.
|
|
96
|
-
portacode-1.4.11.
|
|
97
|
-
portacode-1.4.11.
|
|
93
|
+
portacode-1.4.11.dev5.dist-info/METADATA,sha256=0TExN_oiooXn-VrOIp0aLXmIlzHNkZowv3z-aqy7rA4,13051
|
|
94
|
+
portacode-1.4.11.dev5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
95
|
+
portacode-1.4.11.dev5.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
|
|
96
|
+
portacode-1.4.11.dev5.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
|
|
97
|
+
portacode-1.4.11.dev5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|