portacode 1.4.11.dev10__py3-none-any.whl → 1.4.12.dev0__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.
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.dev10'
32
- __version_tuple__ = version_tuple = (1, 4, 11, 'dev10')
31
+ __version__ = version = '1.4.12.dev0'
32
+ __version_tuple__ = version_tuple = (1, 4, 12, 'dev0')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -367,6 +367,45 @@ Creates a Portacode-managed LXC container, starts it, and bootstraps the Portaco
367
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
368
  * On failure, the device will emit an [`error`](#error) event.
369
369
 
370
+ ### `start_proxmox_container`
371
+
372
+ Starts a previously provisioned, Portacode-managed LXC container. Handled by [`StartProxmoxContainerHandler`](./proxmox_infra.py).
373
+
374
+ **Payload Fields:**
375
+
376
+ * `ctid` (string, required): Identifier of the container to start.
377
+
378
+ **Responses:**
379
+
380
+ * Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="start"` and the refreshed infra snapshot.
381
+ * Emits an [`error`](#error) event when the request cannot be fulfilled (e.g., missing infra config, CT not tagged as managed, or API failure).
382
+
383
+ ### `stop_proxmox_container`
384
+
385
+ Stops a running Portacode-managed container. Handled by [`StopProxmoxContainerHandler`](./proxmox_infra.py).
386
+
387
+ **Payload Fields:**
388
+
389
+ * `ctid` (string, required): Identifier of the container to stop.
390
+
391
+ **Responses:**
392
+
393
+ * Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="stop"` and the refreshed infra snapshot.
394
+ * Emits an [`error`](#error) event on failure.
395
+
396
+ ### `remove_proxmox_container`
397
+
398
+ Deletes a managed container from Proxmox (stopping it first if necessary) and removes the stored metadata file. Handled by [`RemoveProxmoxContainerHandler`](./proxmox_infra.py).
399
+
400
+ **Payload Fields:**
401
+
402
+ * `ctid` (string, required): Identifier of the container to delete.
403
+
404
+ **Responses:**
405
+
406
+ * Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="remove"` and the refreshed infra snapshot after deletion.
407
+ * Emits an [`error`](#error) event on failure.
408
+
370
409
  ### `proxmox_container_created`
371
410
 
372
411
  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.
@@ -1067,21 +1106,37 @@ Provides system information in response to a `system_info` action. Handled by [`
1067
1106
  * `proxmox` (object): Detection hints for Proxmox VE nodes:
1068
1107
  * `is_proxmox_node` (boolean): True when Proxmox artifacts (e.g., `/etc/proxmox-release`) exist.
1069
1108
  * `version` (string|null): Raw contents of `/etc/proxmox-release` when readable.
1070
- * `infra` (object): Portacode infrastructure configuration snapshot:
1071
- * `configured` (boolean): True when `setup_proxmox_infra` stored an API token.
1072
- * `host` (string|null): Hostname used for the API client (usually `localhost`).
1073
- * `node` (string|null): Proxmox node name that was targeted.
1074
- * `user` (string|null): API token owner (e.g., `root@pam`).
1075
- * `token_name` (string|null): API token identifier.
1076
- * `default_storage` (string|null): Storage pool chosen for future containers.
1077
- * `templates` (array[string]): Cached list of available LXC templates.
1078
- * `last_verified` (string|null): ISO timestamp when the token was last validated.
1079
- * `network` (object):
1080
- * `applied` (boolean): True when the bridge/NAT services were successfully configured.
1081
- * `message` (string|null): Informational text about the network setup attempt.
1082
- * `bridge` (string): The bridge interface configured (typically `vmbr1`).
1083
- * `health` (string|null): `"healthy"` when the connectivity verification succeeded.
1084
- * `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
1109
+ * `infra` (object): Portacode infrastructure configuration snapshot:
1110
+ * `configured` (boolean): True when `setup_proxmox_infra` stored an API token.
1111
+ * `host` (string|null): Hostname used for the API client (usually `localhost`).
1112
+ * `node` (string|null): Proxmox node name that was targeted.
1113
+ * `user` (string|null): API token owner (e.g., `root@pam`).
1114
+ * `token_name` (string|null): API token identifier.
1115
+ * `default_storage` (string|null): Storage pool chosen for future containers.
1116
+ * `templates` (array[string]): Cached list of available LXC templates.
1117
+ * `last_verified` (string|null): ISO timestamp when the token was last validated.
1118
+ * `network` (object):
1119
+ * `applied` (boolean): True when the bridge/NAT services were successfully configured.
1120
+ * `message` (string|null): Informational text about the network setup attempt.
1121
+ * `bridge` (string): The bridge interface configured (typically `vmbr1`).
1122
+ * `health` (string|null): `"healthy"` when the connectivity verification succeeded.
1123
+ * `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
1124
+ * `managed_containers` (object): Cached summary of the Portacode-managed containers:
1125
+ * `updated_at` (string): ISO timestamp when this snapshot was last refreshed.
1126
+ * `count` (integer): Number of managed containers.
1127
+ * `total_ram_mib` (integer): RAM footprint summed across all containers.
1128
+ * `total_disk_gib` (integer): Disk footprint summed across all containers.
1129
+ * `total_cpu_share` (number): CPU shares requested across all containers.
1130
+ * `containers` (array[object]): Container summaries with the following fields:
1131
+ * `vmid` (string|null): Numeric CT ID.
1132
+ * `hostname` (string|null): Hostname configured in the CT.
1133
+ * `template` (string|null): Template identifier used.
1134
+ * `storage` (string|null): Storage pool backing the rootfs.
1135
+ * `disk_gib` (integer): Rootfs size in GiB.
1136
+ * `ram_mib` (integer): Memory size in MiB.
1137
+ * `cpu_share` (number): vCPU-equivalent share requested at creation.
1138
+ * `status` (string): Lowercase lifecycle status (e.g., `running`, `stopped`, `deleted`).
1139
+ * `created_at` (string|null): ISO timestamp recorded when the CT was provisioned.
1085
1140
  * `portacode_version` (string): Installed CLI version returned by `portacode.__version__`.
1086
1141
 
1087
1142
  ### `proxmox_infra_configured`
@@ -1133,6 +1188,20 @@ Sent continuously while `create_proxmox_container` runs so dashboards can show a
1133
1188
  * `details` (object, optional): Contains `attempt` (when retries are used) and `error_summary` on failure.
1134
1189
  * `request_id` (string, optional): Mirrors the `create_proxmox_container` request when provided.
1135
1190
 
1191
+ ### `proxmox_container_action`
1192
+
1193
+ Emitted after `start_proxmox_container`, `stop_proxmox_container`, or `remove_proxmox_container` commands complete. Each event includes the refreshed infra snapshot so dashboards can immediately display the latest managed container totals even though the `proxmox.infra.managed_containers` cache updates only every ~30 seconds.
1194
+
1195
+ **Event Fields:**
1196
+
1197
+ * `action` (string): The action that ran (`start`, `stop`, or `remove`).
1198
+ * `success` (boolean): True when the requested action succeeded.
1199
+ * `ctid` (string): Target CT ID.
1200
+ * `message` (string): Human-friendly summary (e.g., `Stopped container 103`).
1201
+ * `status` (string): The container’s new status (e.g., `running`, `stopped`, `deleted`).
1202
+ * `details` (object, optional): Exit status information (e.g., `exitstatus`, `stop_exitstatus`, `delete_exitstatus`).
1203
+ * `infra` (object): Same snapshot described under [`system_info`](#system_info-event) `proxmox.infra`, including the updated `managed_containers` summary.
1204
+
1136
1205
  ### <a name="clock_sync_response"></a>`clock_sync_response`
1137
1206
 
1138
1207
  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.
@@ -46,6 +46,9 @@ from .proxmox_infra import (
46
46
  CreateProxmoxContainerHandler,
47
47
  RevertProxmoxInfraHandler,
48
48
  StartPortacodeServiceHandler,
49
+ StartProxmoxContainerHandler,
50
+ StopProxmoxContainerHandler,
51
+ RemoveProxmoxContainerHandler,
49
52
  )
50
53
 
51
54
  __all__ = [
@@ -86,6 +89,9 @@ __all__ = [
86
89
  "ProjectStateGitRevertHandler",
87
90
  "ProjectStateGitCommitHandler",
88
91
  "StartPortacodeServiceHandler",
92
+ "StartProxmoxContainerHandler",
93
+ "StopProxmoxContainerHandler",
94
+ "RemoveProxmoxContainerHandler",
89
95
  "UpdatePortacodeHandler",
90
96
  "RevertProxmoxInfraHandler",
91
97
  ]
@@ -12,6 +12,7 @@ import stat
12
12
  import subprocess
13
13
  import sys
14
14
  import time
15
+ import threading
15
16
  from datetime import datetime
16
17
  from pathlib import Path
17
18
  from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
@@ -40,6 +41,9 @@ DNS_SERVER = "1.1.1.1"
40
41
  IFACES_PATH = Path("/etc/network/interfaces")
41
42
  SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
42
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()
43
47
 
44
48
  ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
45
49
 
@@ -290,6 +294,90 @@ def _ensure_containers_dir() -> None:
290
294
  CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
291
295
 
292
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
+ now = time.monotonic()
368
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
369
+ cache_ts = _MANAGED_CONTAINERS_CACHE["timestamp"]
370
+ cached = _MANAGED_CONTAINERS_CACHE["summary"]
371
+ if not force and cached and now - cache_ts < _MANAGED_CONTAINERS_CACHE_TTL_S:
372
+ return cached
373
+ records = _load_managed_container_records()
374
+ summary = _build_managed_containers_summary(records)
375
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
376
+ _MANAGED_CONTAINERS_CACHE["timestamp"] = now
377
+ _MANAGED_CONTAINERS_CACHE["summary"] = summary
378
+ return summary
379
+
380
+
293
381
  def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
294
382
  if storage_type in ("lvm", "lvmthin"):
295
383
  return f"{storage}:{disk_gib}"
@@ -365,14 +453,14 @@ def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) ->
365
453
  return ""
366
454
 
367
455
 
368
- def _validate_positive_int(value: Any, default: int) -> int:
456
+ def _validate_positive_number(value: Any, default: float) -> float:
369
457
  try:
370
- candidate = int(value)
458
+ candidate = float(value)
371
459
  if candidate > 0:
372
460
  return candidate
373
461
  except Exception:
374
462
  pass
375
- return default
463
+ return float(default)
376
464
 
377
465
 
378
466
  def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
@@ -424,10 +512,24 @@ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any]
424
512
  return _wait_for_task(proxmox, node, upid)
425
513
 
426
514
 
515
+ def _stop_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
516
+ status = proxmox.nodes(node).lxc(vmid).status.current.get()
517
+ if status.get("status") != "running":
518
+ return status, 0.0
519
+ upid = proxmox.nodes(node).lxc(vmid).status.stop.post()
520
+ return _wait_for_task(proxmox, node, upid)
521
+
522
+
523
+ def _delete_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
524
+ upid = proxmox.nodes(node).lxc(vmid).delete()
525
+ return _wait_for_task(proxmox, node, upid)
526
+
527
+
427
528
  def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
428
529
  _ensure_containers_dir()
429
530
  path = CONTAINERS_DIR / f"ct-{vmid}.json"
430
531
  path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
532
+ _invalidate_managed_containers_cache()
431
533
 
432
534
 
433
535
  def _read_container_record(vmid: int) -> Dict[str, Any]:
@@ -437,6 +539,19 @@ def _read_container_record(vmid: int) -> Dict[str, Any]:
437
539
  return json.loads(path.read_text(encoding="utf-8"))
438
540
 
439
541
 
542
+ def _update_container_record(vmid: int, updates: Dict[str, Any]) -> None:
543
+ record = _read_container_record(vmid)
544
+ record.update(updates)
545
+ _write_container_record(vmid, record)
546
+
547
+
548
+ def _remove_container_record(vmid: int) -> None:
549
+ path = CONTAINERS_DIR / f"ct-{vmid}.json"
550
+ if path.exists():
551
+ path.unlink()
552
+ _invalidate_managed_containers_cache()
553
+
554
+
440
555
  def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
441
556
  templates = config.get("templates") or []
442
557
  default_template = templates[0] if templates else ""
@@ -446,9 +561,9 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
446
561
 
447
562
  bridge = config.get("network", {}).get("bridge", DEFAULT_BRIDGE)
448
563
  hostname = (message.get("hostname") or "").strip()
449
- disk_gib = _validate_positive_int(message.get("disk_gib") or message.get("disk"), 32)
450
- ram_mib = _validate_positive_int(message.get("ram_mib") or message.get("ram"), 2048)
451
- cpus = _validate_positive_int(message.get("cpus"), 1)
564
+ disk_gib = int(max(round(_validate_positive_number(message.get("disk_gib") or message.get("disk"), 3)), 1))
565
+ ram_mib = int(max(round(_validate_positive_number(message.get("ram_mib") or message.get("ram"), 2048)), 1))
566
+ cpus = _validate_positive_number(message.get("cpus"), 1)
452
567
  storage = message.get("storage") or config.get("default_storage") or ""
453
568
  if not storage:
454
569
  raise ValueError("Storage pool could not be determined.")
@@ -473,6 +588,38 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
473
588
  return payload
474
589
 
475
590
 
591
+ def _ensure_infra_configured() -> Dict[str, Any]:
592
+ config = _load_config()
593
+ if not config or not config.get("token_value"):
594
+ raise RuntimeError("Proxmox infrastructure is not configured.")
595
+ return config
596
+
597
+
598
+ def _get_node_from_config(config: Dict[str, Any]) -> str:
599
+ return config.get("node") or DEFAULT_NODE_NAME
600
+
601
+
602
+ def _parse_ctid(message: Dict[str, Any]) -> int:
603
+ for key in ("ctid", "vmid"):
604
+ value = message.get(key)
605
+ if value is not None:
606
+ try:
607
+ return int(str(value).strip())
608
+ except ValueError:
609
+ raise ValueError(f"{key} must be an integer") from None
610
+ raise ValueError("ctid is required")
611
+
612
+
613
+ def _ensure_container_managed(
614
+ proxmox: Any, node: str, vmid: int
615
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
616
+ record = _read_container_record(vmid)
617
+ ct_cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
618
+ if not ct_cfg or MANAGED_MARKER not in (ct_cfg.get("description") or ""):
619
+ raise RuntimeError(f"Container {vmid} is not managed by Portacode.")
620
+ return record, ct_cfg
621
+
622
+
476
623
  def _connect_proxmox(config: Dict[str, Any]) -> Any:
477
624
  ProxmoxAPI = _ensure_proxmoxer()
478
625
  return ProxmoxAPI(
@@ -748,6 +895,7 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
748
895
  _save_config(config)
749
896
  snapshot = build_snapshot(config)
750
897
  snapshot["node_status"] = status
898
+ snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
751
899
  return snapshot
752
900
 
753
901
 
@@ -756,6 +904,7 @@ def get_infra_snapshot() -> Dict[str, Any]:
756
904
  snapshot = build_snapshot(config)
757
905
  if config.get("node_status"):
758
906
  snapshot["node_status"] = config["node_status"]
907
+ snapshot["managed_containers"] = _get_managed_containers_summary()
759
908
  return snapshot
760
909
 
761
910
 
@@ -768,6 +917,7 @@ def revert_infrastructure() -> Dict[str, Any]:
768
917
  snapshot["network"]["applied"] = False
769
918
  snapshot["network"]["message"] = "Reverted to previous network state"
770
919
  snapshot["network"]["bridge"] = DEFAULT_BRIDGE
920
+ snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
771
921
  return snapshot
772
922
 
773
923
 
@@ -905,6 +1055,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
905
1055
  vmid, _ = _instantiate_container(proxmox, node, payload)
906
1056
  payload["vmid"] = vmid
907
1057
  payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1058
+ payload["status"] = "creating"
908
1059
  _write_container_record(vmid, payload)
909
1060
  return proxmox, node, vmid, payload
910
1061
 
@@ -926,6 +1077,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
926
1077
  "Container startup completed.",
927
1078
  _start_container_step,
928
1079
  )
1080
+ _update_container_record(vmid, {"status": "running"})
929
1081
 
930
1082
  def _bootstrap_progress_callback(
931
1083
  step_index: int,
@@ -1103,6 +1255,106 @@ class StartPortacodeServiceHandler(SyncHandler):
1103
1255
  }
1104
1256
 
1105
1257
 
1258
+ class StartProxmoxContainerHandler(SyncHandler):
1259
+ """Start a managed container via the Proxmox API."""
1260
+
1261
+ @property
1262
+ def command_name(self) -> str:
1263
+ return "start_proxmox_container"
1264
+
1265
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1266
+ vmid = _parse_ctid(message)
1267
+ config = _ensure_infra_configured()
1268
+ proxmox = _connect_proxmox(config)
1269
+ node = _get_node_from_config(config)
1270
+ _ensure_container_managed(proxmox, node, vmid)
1271
+
1272
+ status, elapsed = _start_container(proxmox, node, vmid)
1273
+ _update_container_record(vmid, {"status": "running"})
1274
+
1275
+ infra = get_infra_snapshot()
1276
+ return {
1277
+ "event": "proxmox_container_action",
1278
+ "action": "start",
1279
+ "success": True,
1280
+ "ctid": str(vmid),
1281
+ "message": f"Started container {vmid} in {elapsed:.1f}s.",
1282
+ "details": {"exitstatus": status.get("exitstatus")},
1283
+ "status": status.get("status"),
1284
+ "infra": infra,
1285
+ }
1286
+
1287
+
1288
+ class StopProxmoxContainerHandler(SyncHandler):
1289
+ """Stop a managed container via the Proxmox API."""
1290
+
1291
+ @property
1292
+ def command_name(self) -> str:
1293
+ return "stop_proxmox_container"
1294
+
1295
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1296
+ vmid = _parse_ctid(message)
1297
+ config = _ensure_infra_configured()
1298
+ proxmox = _connect_proxmox(config)
1299
+ node = _get_node_from_config(config)
1300
+ _ensure_container_managed(proxmox, node, vmid)
1301
+
1302
+ status, elapsed = _stop_container(proxmox, node, vmid)
1303
+ final_status = status.get("status") or "stopped"
1304
+ _update_container_record(vmid, {"status": final_status})
1305
+
1306
+ infra = get_infra_snapshot()
1307
+ message_text = (
1308
+ f"Container {vmid} is already stopped."
1309
+ if final_status != "running" and elapsed == 0.0
1310
+ else f"Stopped container {vmid} in {elapsed:.1f}s."
1311
+ )
1312
+ return {
1313
+ "event": "proxmox_container_action",
1314
+ "action": "stop",
1315
+ "success": True,
1316
+ "ctid": str(vmid),
1317
+ "message": message_text,
1318
+ "details": {"exitstatus": status.get("exitstatus")},
1319
+ "status": final_status,
1320
+ "infra": infra,
1321
+ }
1322
+
1323
+
1324
+ class RemoveProxmoxContainerHandler(SyncHandler):
1325
+ """Delete a managed container via the Proxmox API."""
1326
+
1327
+ @property
1328
+ def command_name(self) -> str:
1329
+ return "remove_proxmox_container"
1330
+
1331
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1332
+ vmid = _parse_ctid(message)
1333
+ config = _ensure_infra_configured()
1334
+ proxmox = _connect_proxmox(config)
1335
+ node = _get_node_from_config(config)
1336
+ _ensure_container_managed(proxmox, node, vmid)
1337
+
1338
+ stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
1339
+ delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
1340
+ _remove_container_record(vmid)
1341
+
1342
+ infra = get_infra_snapshot()
1343
+ return {
1344
+ "event": "proxmox_container_action",
1345
+ "action": "remove",
1346
+ "success": True,
1347
+ "ctid": str(vmid),
1348
+ "message": f"Deleted container {vmid} in {delete_elapsed:.1f}s.",
1349
+ "details": {
1350
+ "stop_exitstatus": stop_status.get("exitstatus"),
1351
+ "delete_exitstatus": delete_status.get("exitstatus"),
1352
+ },
1353
+ "status": "deleted",
1354
+ "infra": infra,
1355
+ }
1356
+
1357
+
1106
1358
  class ConfigureProxmoxInfraHandler(SyncHandler):
1107
1359
  @property
1108
1360
  def command_name(self) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.11.dev10
3
+ Version: 1.4.12.dev0
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,7 +1,7 @@
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=N01xrWD9pWCsVQSV1BEau1bnnemce8pHIwKp_9Gicbo,721
4
+ portacode/_version.py,sha256=5exIeQXwAxVtu_nbVVxfGhZlbO5I6xDEDIBuQWTo_X4,719
5
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
@@ -14,15 +14,15 @@ portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5wei
14
14
  portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
15
15
  portacode/connection/terminal.py,sha256=07wxG_55JMy3yQ9TXCBldW9h43qCW3U8rv2yzGMx4FM,44757
16
16
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
17
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=Z5L0gJWoHxV7UonHqxHko_PXZd7Z1mbI6yWtmjxna-s,93951
18
- portacode/connection/handlers/__init__.py,sha256=y-Aj5SXqc_QJt7i1xkl7kv381Fd2CIcUiG5gR1F8qkI,2711
17
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=7tBYNEY8EBGAPIMT606BqeHnyMOQIZVlQYpH7me26LY,97962
18
+ portacode/connection/handlers/__init__.py,sha256=WSeBmi65GWFQPYt9M3E10rn0uZ_EPCJzNJOzSf2HZyw,2921
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=dO1LjKOgIqubib_JF6AfILl3cMY3djP7Xq4iPrGPWMY,41192
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=-t9I2ED-jT1FBpgpgkWNRRWcgtGEitTQw5ukScFZWyk,50300
26
26
  portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
27
27
  portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
28
28
  portacode/connection/handlers/system_handlers.py,sha256=AKh7IbwptlLYrbSw5f-DHigvlaKHsg9lDP-lkAUm8cE,10755
@@ -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.dev10.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
67
+ portacode-1.4.12.dev0.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.dev10.dist-info/METADATA,sha256=MOIgg0chGfuiFrYIhUhiPegZ-06JrXL20Y5vsFs2wvo,13052
94
- portacode-1.4.11.dev10.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
95
- portacode-1.4.11.dev10.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
96
- portacode-1.4.11.dev10.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
97
- portacode-1.4.11.dev10.dist-info/RECORD,,
93
+ portacode-1.4.12.dev0.dist-info/METADATA,sha256=Ofq1p3psLwNSji1a0CMinTlbgiVDWQZa2D3sYLcsAzg,13051
94
+ portacode-1.4.12.dev0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
95
+ portacode-1.4.12.dev0.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
96
+ portacode-1.4.12.dev0.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
97
+ portacode-1.4.12.dev0.dist-info/RECORD,,