portacode 1.4.11.dev0__py3-none-any.whl → 1.4.12.dev1__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.

@@ -2,16 +2,20 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
6
7
  import logging
7
8
  import os
9
+ import secrets
8
10
  import shutil
9
11
  import stat
10
12
  import subprocess
11
13
  import sys
14
+ import time
15
+ import threading
12
16
  from datetime import datetime
13
17
  from pathlib import Path
14
- from typing import Any, Dict, Iterable, List, Tuple
18
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
15
19
 
16
20
  import platformdirs
17
21
 
@@ -21,6 +25,10 @@ logger = logging.getLogger(__name__)
21
25
 
22
26
  CONFIG_DIR = Path(platformdirs.user_config_dir("portacode"))
23
27
  CONFIG_PATH = CONFIG_DIR / "proxmox_infra.json"
28
+ REPO_ROOT = Path(__file__).resolve().parents[3]
29
+ NET_SETUP_SCRIPT = REPO_ROOT / "proxmox_management" / "net_setup.py"
30
+ CONTAINERS_DIR = CONFIG_DIR / "containers"
31
+ MANAGED_MARKER = "portacode-managed:true"
24
32
 
25
33
  DEFAULT_HOST = "localhost"
26
34
  DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
@@ -33,6 +41,58 @@ DNS_SERVER = "1.1.1.1"
33
41
  IFACES_PATH = Path("/etc/network/interfaces")
34
42
  SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
35
43
  UNIT_DIR = Path("/etc/systemd/system")
44
+ _MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
45
+ _MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
46
+ _MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
47
+
48
+ ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
49
+
50
+
51
+ def _emit_progress_event(
52
+ handler: SyncHandler,
53
+ *,
54
+ step_index: int,
55
+ total_steps: int,
56
+ step_name: str,
57
+ step_label: str,
58
+ status: str,
59
+ message: str,
60
+ phase: str,
61
+ request_id: Optional[str],
62
+ details: Optional[Dict[str, Any]] = None,
63
+ ) -> None:
64
+ loop = handler.context.get("event_loop")
65
+ if not loop or loop.is_closed():
66
+ logger.debug(
67
+ "progress event skipped (no event loop) step=%s status=%s",
68
+ step_name,
69
+ status,
70
+ )
71
+ return
72
+
73
+ payload: Dict[str, Any] = {
74
+ "event": "proxmox_container_progress",
75
+ "step_name": step_name,
76
+ "step_label": step_label,
77
+ "status": status,
78
+ "phase": phase,
79
+ "step_index": step_index,
80
+ "total_steps": total_steps,
81
+ "message": message,
82
+ }
83
+ if request_id:
84
+ payload["request_id"] = request_id
85
+ if details:
86
+ payload["details"] = details
87
+
88
+ future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
89
+ future.add_done_callback(
90
+ lambda fut: logger.warning(
91
+ "Failed to emit progress event for %s: %s", step_name, fut.exception()
92
+ )
93
+ if fut.exception()
94
+ else None
95
+ )
36
96
 
37
97
 
38
98
  def _call_subprocess(cmd: List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
@@ -214,10 +274,593 @@ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
214
274
  return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
215
275
 
216
276
 
277
+ def _verify_connectivity(timeout: float = 5.0) -> bool:
278
+ try:
279
+ _call_subprocess(["/bin/ping", "-c", "2", "1.1.1.1"], check=True, timeout=timeout)
280
+ return True
281
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
282
+ return False
283
+
284
+
285
+ def _revert_bridge() -> None:
286
+ try:
287
+ if NET_SETUP_SCRIPT.exists():
288
+ _call_subprocess([sys.executable, str(NET_SETUP_SCRIPT), "revert"], check=True)
289
+ except Exception as exc:
290
+ logger.warning("Proxmox bridge revert failed: %s", exc)
291
+
292
+
293
+ def _ensure_containers_dir() -> None:
294
+ CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
295
+
296
+
297
+ def _invalidate_managed_containers_cache() -> None:
298
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
299
+ _MANAGED_CONTAINERS_CACHE["timestamp"] = 0.0
300
+ _MANAGED_CONTAINERS_CACHE["summary"] = None
301
+
302
+
303
+ def _load_managed_container_records() -> List[Dict[str, Any]]:
304
+ _ensure_containers_dir()
305
+ records: List[Dict[str, Any]] = []
306
+ for path in sorted(CONTAINERS_DIR.glob("ct-*.json")):
307
+ try:
308
+ payload = json.loads(path.read_text(encoding="utf-8"))
309
+ except Exception as exc: # pragma: no cover - best effort logging
310
+ logger.debug("Unable to read container record %s: %s", path, exc)
311
+ continue
312
+ records.append(payload)
313
+ return records
314
+
315
+
316
+ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str, Any]:
317
+ total_ram = 0
318
+ total_disk = 0
319
+ total_cpu_share = 0.0
320
+ containers: List[Dict[str, Any]] = []
321
+
322
+ def _as_int(value: Any) -> int:
323
+ try:
324
+ return int(value)
325
+ except (TypeError, ValueError):
326
+ return 0
327
+
328
+ def _as_float(value: Any) -> float:
329
+ try:
330
+ return float(value)
331
+ except (TypeError, ValueError):
332
+ return 0.0
333
+
334
+ for record in sorted(records, key=lambda entry: _as_int(entry.get("vmid"))):
335
+ ram_mib = _as_int(record.get("ram_mib"))
336
+ disk_gib = _as_int(record.get("disk_gib"))
337
+ cpu_share = _as_float(record.get("cpus"))
338
+ total_ram += ram_mib
339
+ total_disk += disk_gib
340
+ total_cpu_share += cpu_share
341
+ status = (record.get("status") or "unknown").lower()
342
+ containers.append(
343
+ {
344
+ "vmid": str(_as_int(record.get("vmid"))) if record.get("vmid") is not None else None,
345
+ "hostname": record.get("hostname"),
346
+ "template": record.get("template"),
347
+ "storage": record.get("storage"),
348
+ "disk_gib": disk_gib,
349
+ "ram_mib": ram_mib,
350
+ "cpu_share": cpu_share,
351
+ "created_at": record.get("created_at"),
352
+ "status": status,
353
+ }
354
+ )
355
+
356
+ return {
357
+ "updated_at": datetime.utcnow().isoformat() + "Z",
358
+ "count": len(containers),
359
+ "total_ram_mib": total_ram,
360
+ "total_disk_gib": total_disk,
361
+ "total_cpu_share": round(total_cpu_share, 2),
362
+ "containers": containers,
363
+ }
364
+
365
+
366
+ def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
367
+ def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
368
+ if not records or not config:
369
+ return
370
+ try:
371
+ proxmox = _connect_proxmox(config)
372
+ node = _get_node_from_config(config)
373
+ statuses = {
374
+ str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
375
+ for ct in proxmox.nodes(node).lxc.get()
376
+ }
377
+ except Exception as exc: # pragma: no cover - best effort
378
+ logger.debug("Failed to refresh container statuses: %s", exc)
379
+ return
380
+ for record in records:
381
+ vmid = record.get("vmid")
382
+ if vmid is None:
383
+ continue
384
+ try:
385
+ vmid_key = str(int(vmid))
386
+ except (ValueError, TypeError):
387
+ continue
388
+ status = statuses.get(vmid_key)
389
+ if status:
390
+ record["status"] = status
391
+
392
+ now = time.monotonic()
393
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
394
+ cache_ts = _MANAGED_CONTAINERS_CACHE["timestamp"]
395
+ cached = _MANAGED_CONTAINERS_CACHE["summary"]
396
+ if not force and cached and now - cache_ts < _MANAGED_CONTAINERS_CACHE_TTL_S:
397
+ return cached
398
+ config = _load_config()
399
+ records = _load_managed_container_records()
400
+ _refresh_container_statuses(records, config)
401
+ summary = _build_managed_containers_summary(records)
402
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
403
+ _MANAGED_CONTAINERS_CACHE["timestamp"] = now
404
+ _MANAGED_CONTAINERS_CACHE["summary"] = summary
405
+ return summary
406
+
407
+
408
+ def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
409
+ if storage_type in ("lvm", "lvmthin"):
410
+ return f"{storage}:{disk_gib}"
411
+ return f"{storage}:{disk_gib}G"
412
+
413
+
414
+ def _get_provisioning_user_info(message: Dict[str, Any]) -> Tuple[str, str, str]:
415
+ user = (message.get("username") or "svcuser").strip() if message else "svcuser"
416
+ user = user or "svcuser"
417
+ password = message.get("password")
418
+ if not password:
419
+ password = secrets.token_urlsafe(10)
420
+ ssh_key = (message.get("ssh_key") or "").strip() if message else ""
421
+ return user, password, ssh_key
422
+
423
+
424
+ def _friendly_step_label(step_name: str) -> str:
425
+ if not step_name:
426
+ return "Step"
427
+ normalized = step_name.replace("_", " ").strip()
428
+ return normalized.capitalize()
429
+
430
+
431
+ def _build_bootstrap_steps(user: str, password: str, ssh_key: str) -> List[Dict[str, Any]]:
432
+ steps = [
433
+ {
434
+ "name": "apt_update",
435
+ "cmd": "apt-get update -y",
436
+ "retries": 4,
437
+ "retry_delay_s": 5,
438
+ "retry_on": [
439
+ "Temporary failure resolving",
440
+ "Could not resolve",
441
+ "Failed to fetch",
442
+ ],
443
+ },
444
+ {
445
+ "name": "install_deps",
446
+ "cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
447
+ "retries": 5,
448
+ "retry_delay_s": 5,
449
+ "retry_on": [
450
+ "lock-frontend",
451
+ "Unable to acquire the dpkg frontend lock",
452
+ "Temporary failure resolving",
453
+ "Could not resolve",
454
+ "Failed to fetch",
455
+ ],
456
+ },
457
+ {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
458
+ {"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
459
+ ]
460
+ if password:
461
+ steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
462
+ if ssh_key:
463
+ steps.append({
464
+ "name": "add_ssh_key",
465
+ "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
466
+ "retries": 0,
467
+ })
468
+ steps.extend([
469
+ {"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
470
+ {"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
471
+ {"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30},
472
+ ])
473
+ return steps
474
+
475
+
476
+ def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
477
+ for entry in storages:
478
+ if entry.get("storage") == storage_name:
479
+ return entry.get("type", "")
480
+ return ""
481
+
482
+
483
+ def _validate_positive_number(value: Any, default: float) -> float:
484
+ try:
485
+ candidate = float(value)
486
+ if candidate > 0:
487
+ return candidate
488
+ except Exception:
489
+ pass
490
+ return float(default)
491
+
492
+
493
+ def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
494
+ start = time.time()
495
+ while True:
496
+ status = proxmox.nodes(node).tasks(upid).status.get()
497
+ if status.get("status") == "stopped":
498
+ return status, time.time() - start
499
+ time.sleep(1)
500
+
501
+
502
+ def _list_running_managed(proxmox: Any, node: str) -> List[Tuple[str, Dict[str, Any]]]:
503
+ entries = []
504
+ for ct in proxmox.nodes(node).lxc.get():
505
+ if ct.get("status") != "running":
506
+ continue
507
+ vmid = str(ct.get("vmid"))
508
+ cfg = proxmox.nodes(node).lxc(vmid).config.get()
509
+ if cfg and MANAGED_MARKER in (cfg.get("description") or ""):
510
+ entries.append((vmid, cfg))
511
+ return entries
512
+
513
+
514
+ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
515
+ status = proxmox.nodes(node).lxc(vmid).status.current.get()
516
+ if status.get("status") == "running":
517
+ uptime = status.get("uptime", 0)
518
+ logger.info("Container %s already running (%ss)", vmid, uptime)
519
+ return status, 0.0
520
+
521
+ node_status = proxmox.nodes(node).status.get()
522
+ mem_total_mb = int(node_status.get("memory", {}).get("total", 0) // (1024**2))
523
+ cores_total = int(node_status.get("cpuinfo", {}).get("cores", 0))
524
+
525
+ running = _list_running_managed(proxmox, node)
526
+ used_mem_mb = sum(int(cfg.get("memory", 0)) for _, cfg in running)
527
+ used_cores = sum(int(cfg.get("cores", 0)) for _, cfg in running)
528
+
529
+ target_cfg = proxmox.nodes(node).lxc(vmid).config.get()
530
+ target_mem_mb = int(target_cfg.get("memory", 0))
531
+ target_cores = int(target_cfg.get("cores", 0))
532
+
533
+ if mem_total_mb and used_mem_mb + target_mem_mb > mem_total_mb:
534
+ raise RuntimeError("Not enough RAM to start this container safely.")
535
+ if cores_total and used_cores + target_cores > cores_total:
536
+ raise RuntimeError("Not enough CPU cores to start this container safely.")
537
+
538
+ upid = proxmox.nodes(node).lxc(vmid).status.start.post()
539
+ return _wait_for_task(proxmox, node, upid)
540
+
541
+
542
+ def _stop_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
543
+ status = proxmox.nodes(node).lxc(vmid).status.current.get()
544
+ if status.get("status") != "running":
545
+ return status, 0.0
546
+ upid = proxmox.nodes(node).lxc(vmid).status.stop.post()
547
+ return _wait_for_task(proxmox, node, upid)
548
+
549
+
550
+ def _delete_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
551
+ upid = proxmox.nodes(node).lxc(vmid).delete()
552
+ return _wait_for_task(proxmox, node, upid)
553
+
554
+
555
+ def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
556
+ _ensure_containers_dir()
557
+ path = CONTAINERS_DIR / f"ct-{vmid}.json"
558
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
559
+ _invalidate_managed_containers_cache()
560
+
561
+
562
+ def _read_container_record(vmid: int) -> Dict[str, Any]:
563
+ path = CONTAINERS_DIR / f"ct-{vmid}.json"
564
+ if not path.exists():
565
+ raise FileNotFoundError(f"Container record {path} missing")
566
+ return json.loads(path.read_text(encoding="utf-8"))
567
+
568
+
569
+ def _update_container_record(vmid: int, updates: Dict[str, Any]) -> None:
570
+ record = _read_container_record(vmid)
571
+ record.update(updates)
572
+ _write_container_record(vmid, record)
573
+
574
+
575
+ def _remove_container_record(vmid: int) -> None:
576
+ path = CONTAINERS_DIR / f"ct-{vmid}.json"
577
+ if path.exists():
578
+ path.unlink()
579
+ _invalidate_managed_containers_cache()
580
+
581
+
582
+ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
583
+ templates = config.get("templates") or []
584
+ default_template = templates[0] if templates else ""
585
+ template = message.get("template") or default_template
586
+ if not template:
587
+ raise ValueError("Container template is required.")
588
+
589
+ bridge = config.get("network", {}).get("bridge", DEFAULT_BRIDGE)
590
+ hostname = (message.get("hostname") or "").strip()
591
+ disk_gib = int(max(round(_validate_positive_number(message.get("disk_gib") or message.get("disk"), 3)), 1))
592
+ ram_mib = int(max(round(_validate_positive_number(message.get("ram_mib") or message.get("ram"), 2048)), 1))
593
+ cpus = _validate_positive_number(message.get("cpus"), 1)
594
+ storage = message.get("storage") or config.get("default_storage") or ""
595
+ if not storage:
596
+ raise ValueError("Storage pool could not be determined.")
597
+
598
+ user, password, ssh_key = _get_provisioning_user_info(message)
599
+
600
+ payload = {
601
+ "template": template,
602
+ "storage": storage,
603
+ "disk_gib": disk_gib,
604
+ "ram_mib": ram_mib,
605
+ "cpus": cpus,
606
+ "hostname": hostname,
607
+ "net0": f"name=eth0,bridge={bridge},ip=dhcp",
608
+ "unprivileged": 1,
609
+ "swap_mb": 0,
610
+ "username": user,
611
+ "password": password,
612
+ "ssh_public_key": ssh_key,
613
+ "description": MANAGED_MARKER,
614
+ }
615
+ return payload
616
+
617
+
618
+ def _ensure_infra_configured() -> Dict[str, Any]:
619
+ config = _load_config()
620
+ if not config or not config.get("token_value"):
621
+ raise RuntimeError("Proxmox infrastructure is not configured.")
622
+ return config
623
+
624
+
625
+ def _get_node_from_config(config: Dict[str, Any]) -> str:
626
+ return config.get("node") or DEFAULT_NODE_NAME
627
+
628
+
629
+ def _parse_ctid(message: Dict[str, Any]) -> int:
630
+ for key in ("ctid", "vmid"):
631
+ value = message.get(key)
632
+ if value is not None:
633
+ try:
634
+ return int(str(value).strip())
635
+ except ValueError:
636
+ raise ValueError(f"{key} must be an integer") from None
637
+ raise ValueError("ctid is required")
638
+
639
+
640
+ def _ensure_container_managed(
641
+ proxmox: Any, node: str, vmid: int
642
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
643
+ record = _read_container_record(vmid)
644
+ ct_cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
645
+ if not ct_cfg or MANAGED_MARKER not in (ct_cfg.get("description") or ""):
646
+ raise RuntimeError(f"Container {vmid} is not managed by Portacode.")
647
+ return record, ct_cfg
648
+
649
+
650
+ def _connect_proxmox(config: Dict[str, Any]) -> Any:
651
+ ProxmoxAPI = _ensure_proxmoxer()
652
+ return ProxmoxAPI(
653
+ config.get("host", DEFAULT_HOST),
654
+ user=config.get("user"),
655
+ token_name=config.get("token_name"),
656
+ token_value=config.get("token_value"),
657
+ verify_ssl=config.get("verify_ssl", False),
658
+ timeout=60,
659
+ )
660
+
661
+
662
+ def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str, Any]:
663
+ full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
664
+ start = time.time()
665
+ proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, input=input_text)
666
+ return {
667
+ "cmd": cmd,
668
+ "returncode": proc.returncode,
669
+ "stdout": proc.stdout.strip(),
670
+ "stderr": proc.stderr.strip(),
671
+ "elapsed_s": round(time.time() - start, 2),
672
+ }
673
+
674
+
675
+ def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
676
+ res = _run_pct(vmid, cmd)
677
+ if res["returncode"] != 0:
678
+ raise RuntimeError(res.get("stderr") or res.get("stdout") or "command failed")
679
+ return res
680
+
681
+
682
+ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
683
+ cmd = ["pct", "exec", str(vmid), "--", "bash", "-lc", f"su - {user} -c 'portacode connect'"]
684
+ proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
685
+ start = time.time()
686
+
687
+ data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
688
+ data_dir = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
689
+ key_dir = f"{data_dir}/portacode/keys"
690
+ pub_path = f"{key_dir}/id_portacode.pub"
691
+ priv_path = f"{key_dir}/id_portacode"
692
+
693
+ def file_size(path: str) -> Optional[int]:
694
+ stat_cmd = f"su - {user} -c 'test -s {path} && stat -c %s {path}'"
695
+ res = _run_pct(vmid, stat_cmd)
696
+ if res["returncode"] != 0:
697
+ return None
698
+ try:
699
+ return int(res["stdout"].strip())
700
+ except ValueError:
701
+ return None
702
+
703
+ last_pub = last_priv = None
704
+ stable = 0
705
+ while time.time() - start < timeout_s:
706
+ if proc.poll() is not None:
707
+ out, err = proc.communicate(timeout=1)
708
+ return {
709
+ "ok": False,
710
+ "error": "portacode connect exited before keys were created",
711
+ "stdout": (out or "").strip(),
712
+ "stderr": (err or "").strip(),
713
+ }
714
+ pub_size = file_size(pub_path)
715
+ priv_size = file_size(priv_path)
716
+ if pub_size and priv_size:
717
+ if pub_size == last_pub and priv_size == last_priv:
718
+ stable += 1
719
+ else:
720
+ stable = 0
721
+ last_pub, last_priv = pub_size, priv_size
722
+ if stable >= 1:
723
+ break
724
+ time.sleep(1)
725
+
726
+ if stable < 1:
727
+ proc.terminate()
728
+ try:
729
+ proc.wait(timeout=3)
730
+ except subprocess.TimeoutExpired:
731
+ proc.kill()
732
+ out, err = proc.communicate(timeout=1)
733
+ return {
734
+ "ok": False,
735
+ "error": "timed out waiting for portacode key files",
736
+ "stdout": (out or "").strip(),
737
+ "stderr": (err or "").strip(),
738
+ }
739
+
740
+ proc.terminate()
741
+ try:
742
+ proc.wait(timeout=3)
743
+ except subprocess.TimeoutExpired:
744
+ proc.kill()
745
+
746
+ key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
747
+ return {
748
+ "ok": True,
749
+ "public_key": key_res["stdout"].strip(),
750
+ }
751
+
752
+
753
+ def _summarize_error(res: Dict[str, Any]) -> str:
754
+ text = f"{res.get('stdout','')}\n{res.get('stderr','')}"
755
+ if "No space left on device" in text:
756
+ return "Disk full inside container; increase rootfs or clean apt cache."
757
+ if "Unable to acquire the dpkg frontend lock" in text or "lock-frontend" in text:
758
+ return "Another apt/dpkg process is running; retry after it finishes."
759
+ if "Temporary failure resolving" in text or "Could not resolve" in text:
760
+ return "DNS/network resolution failed inside container."
761
+ if "Failed to fetch" in text:
762
+ return "Package repo fetch failed; check network and apt sources."
763
+ return "Command failed; see stdout/stderr for details."
764
+
765
+
766
+ def _run_setup_steps(
767
+ vmid: int,
768
+ steps: List[Dict[str, Any]],
769
+ user: str,
770
+ progress_callback: Optional[ProgressCallback] = None,
771
+ start_index: int = 1,
772
+ total_steps: Optional[int] = None,
773
+ ) -> Tuple[List[Dict[str, Any]], bool]:
774
+ results: List[Dict[str, Any]] = []
775
+ computed_total = total_steps if total_steps is not None else start_index + len(steps) - 1
776
+ for offset, step in enumerate(steps):
777
+ step_index = start_index + offset
778
+ if progress_callback:
779
+ progress_callback(step_index, computed_total, step, "in_progress", None)
780
+
781
+ if step.get("type") == "portacode_connect":
782
+ res = _portacode_connect_and_read_key(vmid, user, timeout_s=step.get("timeout_s", 10))
783
+ res["name"] = step["name"]
784
+ results.append(res)
785
+ if not res.get("ok"):
786
+ if progress_callback:
787
+ progress_callback(step_index, computed_total, step, "failed", res)
788
+ return results, False
789
+ if progress_callback:
790
+ progress_callback(step_index, computed_total, step, "completed", res)
791
+ continue
792
+
793
+ attempts = 0
794
+ retry_on = step.get("retry_on", [])
795
+ max_attempts = step.get("retries", 0) + 1
796
+ while True:
797
+ attempts += 1
798
+ res = _run_pct(vmid, step["cmd"])
799
+ res["name"] = step["name"]
800
+ res["attempt"] = attempts
801
+ if res["returncode"] != 0:
802
+ res["error_summary"] = _summarize_error(res)
803
+ results.append(res)
804
+ if res["returncode"] == 0:
805
+ if progress_callback:
806
+ progress_callback(step_index, computed_total, step, "completed", res)
807
+ break
808
+
809
+ will_retry = False
810
+ if attempts < max_attempts and retry_on:
811
+ stderr_stdout = (res.get("stderr", "") + res.get("stdout", ""))
812
+ if any(tok in stderr_stdout for tok in retry_on):
813
+ will_retry = True
814
+
815
+ if progress_callback:
816
+ status = "retrying" if will_retry else "failed"
817
+ progress_callback(step_index, computed_total, step, status, res)
818
+
819
+ if will_retry:
820
+ time.sleep(step.get("retry_delay_s", 3))
821
+ continue
822
+
823
+ return results, False
824
+ return results, True
825
+
826
+
827
+ def _bootstrap_portacode(
828
+ vmid: int,
829
+ user: str,
830
+ password: str,
831
+ ssh_key: str,
832
+ steps: Optional[List[Dict[str, Any]]] = None,
833
+ progress_callback: Optional[ProgressCallback] = None,
834
+ start_index: int = 1,
835
+ total_steps: Optional[int] = None,
836
+ ) -> Tuple[str, List[Dict[str, Any]]]:
837
+ actual_steps = steps if steps is not None else _build_bootstrap_steps(user, password, ssh_key)
838
+ results, ok = _run_setup_steps(
839
+ vmid,
840
+ actual_steps,
841
+ user,
842
+ progress_callback=progress_callback,
843
+ start_index=start_index,
844
+ total_steps=total_steps,
845
+ )
846
+ if not ok:
847
+ raise RuntimeError("Portacode bootstrap steps failed.")
848
+ key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
849
+ public_key = key_step.get("public_key") if key_step else None
850
+ if not public_key:
851
+ raise RuntimeError("Portacode connect did not return a public key.")
852
+ return public_key, results
853
+
854
+
217
855
  def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
218
- if not config:
219
- return {"configured": False}
220
856
  network = config.get("network", {})
857
+ base_network = {
858
+ "applied": network.get("applied", False),
859
+ "message": network.get("message"),
860
+ "bridge": network.get("bridge", DEFAULT_BRIDGE),
861
+ }
862
+ if not config:
863
+ return {"configured": False, "network": base_network}
221
864
  return {
222
865
  "configured": True,
223
866
  "host": config.get("host"),
@@ -227,11 +870,7 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
227
870
  "default_storage": config.get("default_storage"),
228
871
  "templates": config.get("templates") or [],
229
872
  "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
- },
873
+ "network": base_network,
235
874
  }
236
875
 
237
876
 
@@ -254,6 +893,13 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
254
893
  network: Dict[str, Any] = {}
255
894
  try:
256
895
  network = _ensure_bridge()
896
+ # Wait for network convergence before validating connectivity
897
+ time.sleep(2)
898
+ if _verify_connectivity():
899
+ network["health"] = "healthy"
900
+ else:
901
+ network = {"applied": False, "bridge": DEFAULT_BRIDGE, "message": "Connectivity check failed; bridge reverted"}
902
+ _revert_bridge()
257
903
  except PermissionError as exc:
258
904
  network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
259
905
  logger.warning("Bridge setup skipped: %s", exc)
@@ -276,6 +922,7 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
276
922
  _save_config(config)
277
923
  snapshot = build_snapshot(config)
278
924
  snapshot["node_status"] = status
925
+ snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
279
926
  return snapshot
280
927
 
281
928
 
@@ -284,9 +931,457 @@ def get_infra_snapshot() -> Dict[str, Any]:
284
931
  snapshot = build_snapshot(config)
285
932
  if config.get("node_status"):
286
933
  snapshot["node_status"] = config["node_status"]
934
+ snapshot["managed_containers"] = _get_managed_containers_summary()
935
+ return snapshot
936
+
937
+
938
+ def revert_infrastructure() -> Dict[str, Any]:
939
+ _revert_bridge()
940
+ if CONFIG_PATH.exists():
941
+ CONFIG_PATH.unlink()
942
+ snapshot = build_snapshot({})
943
+ snapshot["network"] = snapshot.get("network", {})
944
+ snapshot["network"]["applied"] = False
945
+ snapshot["network"]["message"] = "Reverted to previous network state"
946
+ snapshot["network"]["bridge"] = DEFAULT_BRIDGE
947
+ snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
287
948
  return snapshot
288
949
 
289
950
 
951
+ def _allocate_vmid(proxmox: Any) -> int:
952
+ return int(proxmox.cluster.nextid.get())
953
+
954
+
955
+ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) -> Tuple[int, float]:
956
+ from proxmoxer.core import ResourceException
957
+
958
+ storage_type = _get_storage_type(proxmox.nodes(node).storage.get(), payload["storage"])
959
+ rootfs = _format_rootfs(payload["storage"], payload["disk_gib"], storage_type)
960
+ vmid = _allocate_vmid(proxmox)
961
+ if not payload.get("hostname"):
962
+ payload["hostname"] = f"ct{vmid}"
963
+ try:
964
+ upid = proxmox.nodes(node).lxc.create(
965
+ vmid=vmid,
966
+ hostname=payload["hostname"],
967
+ ostemplate=payload["template"],
968
+ rootfs=rootfs,
969
+ memory=int(payload["ram_mib"]),
970
+ swap=int(payload.get("swap_mb", 0)),
971
+ cores=int(payload.get("cpus", 1)),
972
+ cpuunits=int(payload.get("cpuunits", 256)),
973
+ net0=payload["net0"],
974
+ unprivileged=int(payload.get("unprivileged", 1)),
975
+ description=payload.get("description", MANAGED_MARKER),
976
+ password=payload.get("password") or None,
977
+ ssh_public_keys=payload.get("ssh_public_key") or None,
978
+ )
979
+ status, elapsed = _wait_for_task(proxmox, node, upid)
980
+ return vmid, elapsed
981
+ except ResourceException as exc:
982
+ raise RuntimeError(f"Failed to create container: {exc}") from exc
983
+
984
+
985
+ class CreateProxmoxContainerHandler(SyncHandler):
986
+ """Provision a new managed LXC container via the Proxmox API."""
987
+
988
+ @property
989
+ def command_name(self) -> str:
990
+ return "create_proxmox_container"
991
+
992
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
993
+ logger.info("create_proxmox_container command received")
994
+ request_id = message.get("request_id")
995
+ bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
996
+ bootstrap_steps = _build_bootstrap_steps(bootstrap_user, bootstrap_password, bootstrap_ssh_key)
997
+ total_steps = 3 + len(bootstrap_steps) + 2
998
+ current_step_index = 1
999
+
1000
+ def _run_lifecycle_step(
1001
+ step_name: str,
1002
+ step_label: str,
1003
+ start_message: str,
1004
+ success_message: str,
1005
+ action,
1006
+ ):
1007
+ nonlocal current_step_index
1008
+ step_index = current_step_index
1009
+ _emit_progress_event(self,
1010
+ step_index=step_index,
1011
+ total_steps=total_steps,
1012
+ step_name=step_name,
1013
+ step_label=step_label,
1014
+ status="in_progress",
1015
+ message=start_message,
1016
+ phase="lifecycle",
1017
+ request_id=request_id,
1018
+ )
1019
+ try:
1020
+ result = action()
1021
+ except Exception as exc:
1022
+ _emit_progress_event(
1023
+ self,
1024
+ step_index=step_index,
1025
+ total_steps=total_steps,
1026
+ step_name=step_name,
1027
+ step_label=step_label,
1028
+ status="failed",
1029
+ message=f"{step_label} failed: {exc}",
1030
+ phase="lifecycle",
1031
+ request_id=request_id,
1032
+ details={"error": str(exc)},
1033
+ )
1034
+ raise
1035
+ _emit_progress_event(
1036
+ self,
1037
+ step_index=step_index,
1038
+ total_steps=total_steps,
1039
+ step_name=step_name,
1040
+ step_label=step_label,
1041
+ status="completed",
1042
+ message=success_message,
1043
+ phase="lifecycle",
1044
+ request_id=request_id,
1045
+ )
1046
+ current_step_index += 1
1047
+ return result
1048
+
1049
+ def _validate_environment():
1050
+ if os.geteuid() != 0:
1051
+ raise PermissionError("Container creation requires root privileges.")
1052
+ config = _load_config()
1053
+ if not config or not config.get("token_value"):
1054
+ raise ValueError("Proxmox infrastructure is not configured.")
1055
+ if not config.get("network", {}).get("applied"):
1056
+ raise RuntimeError("Proxmox bridge setup must be applied before creating containers.")
1057
+ return config
1058
+
1059
+ config = _run_lifecycle_step(
1060
+ "validate_environment",
1061
+ "Validating infrastructure",
1062
+ "Checking token, permissions, and bridge setup…",
1063
+ "Infrastructure validated.",
1064
+ _validate_environment,
1065
+ )
1066
+
1067
+ def _create_container():
1068
+ proxmox = _connect_proxmox(config)
1069
+ node = config.get("node") or DEFAULT_NODE_NAME
1070
+ payload = _build_container_payload(message, config)
1071
+ payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
1072
+ payload["memory"] = int(payload["ram_mib"])
1073
+ payload["node"] = node
1074
+ logger.debug(
1075
+ "Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
1076
+ node,
1077
+ payload["template"],
1078
+ payload["ram_mib"],
1079
+ payload["cpus"],
1080
+ payload["storage"],
1081
+ )
1082
+ vmid, _ = _instantiate_container(proxmox, node, payload)
1083
+ payload["vmid"] = vmid
1084
+ payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1085
+ payload["status"] = "creating"
1086
+ _write_container_record(vmid, payload)
1087
+ return proxmox, node, vmid, payload
1088
+
1089
+ proxmox, node, vmid, payload = _run_lifecycle_step(
1090
+ "create_container",
1091
+ "Creating container",
1092
+ "Provisioning the LXC container…",
1093
+ "Container created.",
1094
+ _create_container,
1095
+ )
1096
+
1097
+ def _start_container_step():
1098
+ _start_container(proxmox, node, vmid)
1099
+
1100
+ _run_lifecycle_step(
1101
+ "start_container",
1102
+ "Starting container",
1103
+ "Booting the container…",
1104
+ "Container startup completed.",
1105
+ _start_container_step,
1106
+ )
1107
+ _update_container_record(vmid, {"status": "running"})
1108
+
1109
+ def _bootstrap_progress_callback(
1110
+ step_index: int,
1111
+ total: int,
1112
+ step: Dict[str, Any],
1113
+ status: str,
1114
+ result: Optional[Dict[str, Any]],
1115
+ ):
1116
+ label = step.get("display_name") or _friendly_step_label(step.get("name", "bootstrap"))
1117
+ error_summary = (result or {}).get("error_summary") or (result or {}).get("error")
1118
+ attempt = (result or {}).get("attempt")
1119
+ if status == "in_progress":
1120
+ message_text = f"{label} is running…"
1121
+ elif status == "completed":
1122
+ message_text = f"{label} completed."
1123
+ elif status == "retrying":
1124
+ attempt_desc = f" (attempt {attempt})" if attempt else ""
1125
+ message_text = f"{label} failed{attempt_desc}; retrying…"
1126
+ else:
1127
+ message_text = f"{label} failed"
1128
+ if error_summary:
1129
+ message_text += f": {error_summary}"
1130
+ details: Dict[str, Any] = {}
1131
+ if attempt:
1132
+ details["attempt"] = attempt
1133
+ if error_summary:
1134
+ details["error_summary"] = error_summary
1135
+ _emit_progress_event(
1136
+ self,
1137
+ step_index=step_index,
1138
+ total_steps=total,
1139
+ step_name=step.get("name", "bootstrap"),
1140
+ step_label=label,
1141
+ status=status,
1142
+ message=message_text,
1143
+ phase="bootstrap",
1144
+ request_id=request_id,
1145
+ details=details or None,
1146
+ )
1147
+
1148
+ public_key, steps = _bootstrap_portacode(
1149
+ vmid,
1150
+ payload["username"],
1151
+ payload["password"],
1152
+ payload["ssh_public_key"],
1153
+ steps=bootstrap_steps,
1154
+ progress_callback=_bootstrap_progress_callback,
1155
+ start_index=current_step_index,
1156
+ total_steps=total_steps,
1157
+ )
1158
+ current_step_index += len(bootstrap_steps)
1159
+
1160
+ return {
1161
+ "event": "proxmox_container_created",
1162
+ "success": True,
1163
+ "message": f"Container {vmid} is ready and Portacode key captured.",
1164
+ "ctid": str(vmid),
1165
+ "public_key": public_key,
1166
+ "container": {
1167
+ "vmid": vmid,
1168
+ "hostname": payload["hostname"],
1169
+ "template": payload["template"],
1170
+ "storage": payload["storage"],
1171
+ "disk_gib": payload["disk_gib"],
1172
+ "ram_mib": payload["ram_mib"],
1173
+ "cpus": payload["cpus"],
1174
+ },
1175
+ "setup_steps": steps,
1176
+ }
1177
+
1178
+
1179
+ class StartPortacodeServiceHandler(SyncHandler):
1180
+ """Start the Portacode service inside a newly created container."""
1181
+
1182
+ @property
1183
+ def command_name(self) -> str:
1184
+ return "start_portacode_service"
1185
+
1186
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1187
+ ctid = message.get("ctid")
1188
+ if not ctid:
1189
+ raise ValueError("ctid is required")
1190
+ try:
1191
+ vmid = int(ctid)
1192
+ except ValueError:
1193
+ raise ValueError("ctid must be an integer")
1194
+
1195
+ record = _read_container_record(vmid)
1196
+ user = record.get("username")
1197
+ password = record.get("password")
1198
+ if not user or not password:
1199
+ raise RuntimeError("Container credentials unavailable")
1200
+
1201
+ start_index = int(message.get("step_index", 1))
1202
+ total_steps = int(message.get("total_steps", start_index + 2))
1203
+ request_id = message.get("request_id")
1204
+
1205
+ auth_step_name = "setup_device_authentication"
1206
+ auth_label = "Setting up device authentication"
1207
+ _emit_progress_event(
1208
+ self,
1209
+ step_index=start_index,
1210
+ total_steps=total_steps,
1211
+ step_name=auth_step_name,
1212
+ step_label=auth_label,
1213
+ status="in_progress",
1214
+ message="Notifying the server of the new device…",
1215
+ phase="service",
1216
+ request_id=request_id,
1217
+ )
1218
+ _emit_progress_event(
1219
+ self,
1220
+ step_index=start_index,
1221
+ total_steps=total_steps,
1222
+ step_name=auth_step_name,
1223
+ step_label=auth_label,
1224
+ status="completed",
1225
+ message="Authentication metadata recorded.",
1226
+ phase="service",
1227
+ request_id=request_id,
1228
+ )
1229
+
1230
+ install_step = start_index + 1
1231
+ install_label = "Launching Portacode service"
1232
+ _emit_progress_event(
1233
+ self,
1234
+ step_index=install_step,
1235
+ total_steps=total_steps,
1236
+ step_name="launch_portacode_service",
1237
+ step_label=install_label,
1238
+ status="in_progress",
1239
+ message="Running sudo portacode service install…",
1240
+ phase="service",
1241
+ request_id=request_id,
1242
+ )
1243
+
1244
+ cmd = f"su - {user} -c 'sudo -S portacode service install'"
1245
+ res = _run_pct(vmid, cmd, input_text=password + "\n")
1246
+
1247
+ if res["returncode"] != 0:
1248
+ _emit_progress_event(
1249
+ self,
1250
+ step_index=install_step,
1251
+ total_steps=total_steps,
1252
+ step_name="launch_portacode_service",
1253
+ step_label=install_label,
1254
+ status="failed",
1255
+ message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
1256
+ phase="service",
1257
+ request_id=request_id,
1258
+ details={
1259
+ "stderr": res.get("stderr"),
1260
+ "stdout": res.get("stdout"),
1261
+ },
1262
+ )
1263
+ raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1264
+
1265
+ _emit_progress_event(
1266
+ self,
1267
+ step_index=install_step,
1268
+ total_steps=total_steps,
1269
+ step_name="launch_portacode_service",
1270
+ step_label=install_label,
1271
+ status="completed",
1272
+ message="Portacode service install finished.",
1273
+ phase="service",
1274
+ request_id=request_id,
1275
+ )
1276
+
1277
+ return {
1278
+ "event": "proxmox_service_started",
1279
+ "success": True,
1280
+ "message": "Portacode service install completed",
1281
+ "ctid": str(vmid),
1282
+ }
1283
+
1284
+
1285
+ class StartProxmoxContainerHandler(SyncHandler):
1286
+ """Start a managed container via the Proxmox API."""
1287
+
1288
+ @property
1289
+ def command_name(self) -> str:
1290
+ return "start_proxmox_container"
1291
+
1292
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1293
+ vmid = _parse_ctid(message)
1294
+ config = _ensure_infra_configured()
1295
+ proxmox = _connect_proxmox(config)
1296
+ node = _get_node_from_config(config)
1297
+ _ensure_container_managed(proxmox, node, vmid)
1298
+
1299
+ status, elapsed = _start_container(proxmox, node, vmid)
1300
+ _update_container_record(vmid, {"status": "running"})
1301
+
1302
+ infra = get_infra_snapshot()
1303
+ return {
1304
+ "event": "proxmox_container_action",
1305
+ "action": "start",
1306
+ "success": True,
1307
+ "ctid": str(vmid),
1308
+ "message": f"Started container {vmid} in {elapsed:.1f}s.",
1309
+ "details": {"exitstatus": status.get("exitstatus")},
1310
+ "status": status.get("status"),
1311
+ "infra": infra,
1312
+ }
1313
+
1314
+
1315
+ class StopProxmoxContainerHandler(SyncHandler):
1316
+ """Stop a managed container via the Proxmox API."""
1317
+
1318
+ @property
1319
+ def command_name(self) -> str:
1320
+ return "stop_proxmox_container"
1321
+
1322
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1323
+ vmid = _parse_ctid(message)
1324
+ config = _ensure_infra_configured()
1325
+ proxmox = _connect_proxmox(config)
1326
+ node = _get_node_from_config(config)
1327
+ _ensure_container_managed(proxmox, node, vmid)
1328
+
1329
+ status, elapsed = _stop_container(proxmox, node, vmid)
1330
+ final_status = status.get("status") or "stopped"
1331
+ _update_container_record(vmid, {"status": final_status})
1332
+
1333
+ infra = get_infra_snapshot()
1334
+ message_text = (
1335
+ f"Container {vmid} is already stopped."
1336
+ if final_status != "running" and elapsed == 0.0
1337
+ else f"Stopped container {vmid} in {elapsed:.1f}s."
1338
+ )
1339
+ return {
1340
+ "event": "proxmox_container_action",
1341
+ "action": "stop",
1342
+ "success": True,
1343
+ "ctid": str(vmid),
1344
+ "message": message_text,
1345
+ "details": {"exitstatus": status.get("exitstatus")},
1346
+ "status": final_status,
1347
+ "infra": infra,
1348
+ }
1349
+
1350
+
1351
+ class RemoveProxmoxContainerHandler(SyncHandler):
1352
+ """Delete a managed container via the Proxmox API."""
1353
+
1354
+ @property
1355
+ def command_name(self) -> str:
1356
+ return "remove_proxmox_container"
1357
+
1358
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1359
+ vmid = _parse_ctid(message)
1360
+ config = _ensure_infra_configured()
1361
+ proxmox = _connect_proxmox(config)
1362
+ node = _get_node_from_config(config)
1363
+ _ensure_container_managed(proxmox, node, vmid)
1364
+
1365
+ stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
1366
+ delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
1367
+ _remove_container_record(vmid)
1368
+
1369
+ infra = get_infra_snapshot()
1370
+ return {
1371
+ "event": "proxmox_container_action",
1372
+ "action": "remove",
1373
+ "success": True,
1374
+ "ctid": str(vmid),
1375
+ "message": f"Deleted container {vmid} in {delete_elapsed:.1f}s.",
1376
+ "details": {
1377
+ "stop_exitstatus": stop_status.get("exitstatus"),
1378
+ "delete_exitstatus": delete_status.get("exitstatus"),
1379
+ },
1380
+ "status": "deleted",
1381
+ "infra": infra,
1382
+ }
1383
+
1384
+
290
1385
  class ConfigureProxmoxInfraHandler(SyncHandler):
291
1386
  @property
292
1387
  def command_name(self) -> str:
@@ -305,3 +1400,18 @@ class ConfigureProxmoxInfraHandler(SyncHandler):
305
1400
  "message": "Proxmox infrastructure configured",
306
1401
  "infra": snapshot,
307
1402
  }
1403
+
1404
+
1405
+ class RevertProxmoxInfraHandler(SyncHandler):
1406
+ @property
1407
+ def command_name(self) -> str:
1408
+ return "revert_proxmox_infra"
1409
+
1410
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1411
+ snapshot = revert_infrastructure()
1412
+ return {
1413
+ "event": "proxmox_infra_reverted",
1414
+ "success": True,
1415
+ "message": "Proxmox infrastructure configuration reverted",
1416
+ "infra": snapshot,
1417
+ }