portacode 1.4.11.dev5__py3-none-any.whl → 1.4.12.dev3__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,17 +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
12
14
  import time
15
+ import threading
13
16
  from datetime import datetime
14
17
  from pathlib import Path
15
- from typing import Any, Dict, Iterable, List, Optional, Tuple
18
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
16
19
 
17
20
  import platformdirs
18
21
 
@@ -38,6 +41,58 @@ DNS_SERVER = "1.1.1.1"
38
41
  IFACES_PATH = Path("/etc/network/interfaces")
39
42
  SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
40
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
+ )
41
96
 
42
97
 
43
98
  def _call_subprocess(cmd: List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
@@ -239,12 +294,185 @@ def _ensure_containers_dir() -> None:
239
294
  CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
240
295
 
241
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
+
242
408
  def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
243
409
  if storage_type in ("lvm", "lvmthin"):
244
410
  return f"{storage}:{disk_gib}"
245
411
  return f"{storage}:{disk_gib}G"
246
412
 
247
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
+
248
476
  def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
249
477
  for entry in storages:
250
478
  if entry.get("storage") == storage_name:
@@ -252,14 +480,14 @@ def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) ->
252
480
  return ""
253
481
 
254
482
 
255
- def _validate_positive_int(value: Any, default: int) -> int:
483
+ def _validate_positive_number(value: Any, default: float) -> float:
256
484
  try:
257
- candidate = int(value)
485
+ candidate = float(value)
258
486
  if candidate > 0:
259
487
  return candidate
260
488
  except Exception:
261
489
  pass
262
- return default
490
+ return float(default)
263
491
 
264
492
 
265
493
  def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
@@ -311,10 +539,44 @@ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any]
311
539
  return _wait_for_task(proxmox, node, upid)
312
540
 
313
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
+
314
555
  def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
315
556
  _ensure_containers_dir()
316
557
  path = CONTAINERS_DIR / f"ct-{vmid}.json"
317
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()
318
580
 
319
581
 
320
582
  def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
@@ -326,16 +588,14 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
326
588
 
327
589
  bridge = config.get("network", {}).get("bridge", DEFAULT_BRIDGE)
328
590
  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)
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"), 0.2)
332
594
  storage = message.get("storage") or config.get("default_storage") or ""
333
595
  if not storage:
334
596
  raise ValueError("Storage pool could not be determined.")
335
597
 
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()
598
+ user, password, ssh_key = _get_provisioning_user_info(message)
339
599
 
340
600
  payload = {
341
601
  "template": template,
@@ -355,6 +615,38 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
355
615
  return payload
356
616
 
357
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
+
358
650
  def _connect_proxmox(config: Dict[str, Any]) -> Any:
359
651
  ProxmoxAPI = _ensure_proxmoxer()
360
652
  return ProxmoxAPI(
@@ -367,10 +659,10 @@ def _connect_proxmox(config: Dict[str, Any]) -> Any:
367
659
  )
368
660
 
369
661
 
370
- def _run_pct(vmid: int, cmd: str) -> Dict[str, Any]:
662
+ def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str, Any]:
371
663
  full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
372
664
  start = time.time()
373
- proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
665
+ proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, input=input_text)
374
666
  return {
375
667
  "cmd": cmd,
376
668
  "returncode": proc.returncode,
@@ -471,18 +763,36 @@ def _summarize_error(res: Dict[str, Any]) -> str:
471
763
  return "Command failed; see stdout/stderr for details."
472
764
 
473
765
 
474
- def _run_setup_steps(vmid: int, steps: List[Dict[str, Any]], user: str) -> Tuple[List[Dict[str, Any]], bool]:
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]:
475
774
  results: List[Dict[str, Any]] = []
476
- for step in steps:
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
+
477
781
  if step.get("type") == "portacode_connect":
478
782
  res = _portacode_connect_and_read_key(vmid, user, timeout_s=step.get("timeout_s", 10))
479
783
  res["name"] = step["name"]
480
784
  results.append(res)
481
785
  if not res.get("ok"):
786
+ if progress_callback:
787
+ progress_callback(step_index, computed_total, step, "failed", res)
482
788
  return results, False
789
+ if progress_callback:
790
+ progress_callback(step_index, computed_total, step, "completed", res)
483
791
  continue
484
792
 
485
793
  attempts = 0
794
+ retry_on = step.get("retry_on", [])
795
+ max_attempts = step.get("retries", 0) + 1
486
796
  while True:
487
797
  attempts += 1
488
798
  res = _run_pct(vmid, step["cmd"])
@@ -492,61 +802,47 @@ def _run_setup_steps(vmid: int, steps: List[Dict[str, Any]], user: str) -> Tuple
492
802
  res["error_summary"] = _summarize_error(res)
493
803
  results.append(res)
494
804
  if res["returncode"] == 0:
805
+ if progress_callback:
806
+ progress_callback(step_index, computed_total, step, "completed", res)
495
807
  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):
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:
500
820
  time.sleep(step.get("retry_delay_s", 3))
501
821
  continue
822
+
502
823
  return results, False
503
824
  return results, True
504
825
 
505
826
 
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)
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
+ )
550
846
  if not ok:
551
847
  raise RuntimeError("Portacode bootstrap steps failed.")
552
848
  key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
@@ -626,6 +922,7 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
626
922
  _save_config(config)
627
923
  snapshot = build_snapshot(config)
628
924
  snapshot["node_status"] = status
925
+ snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
629
926
  return snapshot
630
927
 
631
928
 
@@ -634,6 +931,7 @@ def get_infra_snapshot() -> Dict[str, Any]:
634
931
  snapshot = build_snapshot(config)
635
932
  if config.get("node_status"):
636
933
  snapshot["node_status"] = config["node_status"]
934
+ snapshot["managed_containers"] = _get_managed_containers_summary()
637
935
  return snapshot
638
936
 
639
937
 
@@ -646,6 +944,7 @@ def revert_infrastructure() -> Dict[str, Any]:
646
944
  snapshot["network"]["applied"] = False
647
945
  snapshot["network"]["message"] = "Reverted to previous network state"
648
946
  snapshot["network"]["bridge"] = DEFAULT_BRIDGE
947
+ snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
649
948
  return snapshot
650
949
 
651
950
 
@@ -661,22 +960,22 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
661
960
  vmid = _allocate_vmid(proxmox)
662
961
  if not payload.get("hostname"):
663
962
  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
- )
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=max(int(payload.get("cores", 1)), 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
+ )
680
979
  status, elapsed = _wait_for_task(proxmox, node, upid)
681
980
  return vmid, elapsed
682
981
  except ResourceException as exc:
@@ -692,49 +991,171 @@ class CreateProxmoxContainerHandler(SyncHandler):
692
991
 
693
992
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
694
993
  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"],
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,
720
1065
  )
721
1066
 
722
- try:
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
+ )
723
1082
  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)
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
+ )
731
1096
 
732
- try:
1097
+ def _start_container_step():
733
1098
  _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
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)
738
1159
 
739
1160
  return {
740
1161
  "event": "proxmox_container_created",
@@ -755,6 +1176,212 @@ class CreateProxmoxContainerHandler(SyncHandler):
755
1176
  }
756
1177
 
757
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
+
758
1385
  class ConfigureProxmoxInfraHandler(SyncHandler):
759
1386
  @property
760
1387
  def command_name(self) -> str: