portacode 1.4.17.dev6__py3-none-any.whl → 1.4.17.dev7__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.17.dev6'
32
- __version_tuple__ = version_tuple = (1, 4, 17, 'dev6')
31
+ __version__ = version = '1.4.17.dev7'
32
+ __version_tuple__ = version_tuple = (1, 4, 17, 'dev7')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -45,16 +45,38 @@ DNS_SERVER = "1.1.1.1"
45
45
  IFACES_PATH = Path("/etc/network/interfaces")
46
46
  SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
47
47
  UNIT_DIR = Path("/etc/systemd/system")
48
- _MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
49
- _MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
50
- _MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
51
- _CAPACITY_LOCK = threading.Lock()
52
- _PENDING_ALLOCATIONS = {"ram_mib": 0.0, "disk_gib": 0.0, "cpu_share": 0.0}
48
+ _MANAGED_CONTAINERS_STATE_LOCK = threading.Lock()
49
+ _MANAGED_CONTAINERS_STATE: Dict[str, Any] = {
50
+ "initialized": False,
51
+ "base_summary": None,
52
+ "initial_totals": {"ram_mib": 0, "disk_gib": 0, "cpu_share": 0.0},
53
+ "records": {},
54
+ "pending": {},
55
+ }
53
56
  TEMPLATES_REFRESH_INTERVAL_S = 300
54
57
 
55
58
  ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
56
59
 
57
60
 
61
+ def _emit_host_event(
62
+ handler: SyncHandler,
63
+ payload: Dict[str, Any],
64
+ ) -> None:
65
+ loop = handler.context.get("event_loop")
66
+ if not loop or loop.is_closed():
67
+ logger.debug("host event skipped (no event loop) event=%s", payload.get("event"))
68
+ return
69
+
70
+ future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
71
+ future.add_done_callback(
72
+ lambda fut: logger.warning(
73
+ "Failed to emit host event %s: %s", payload.get("event"), fut.exception()
74
+ )
75
+ if fut.exception()
76
+ else None
77
+ )
78
+
79
+
58
80
  def _emit_progress_event(
59
81
  handler: SyncHandler,
60
82
  *,
@@ -422,14 +444,7 @@ def _to_mib(value: Any) -> float:
422
444
 
423
445
 
424
446
  def _pick_container_ram_mib(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> float:
425
- """
426
- Proxmox config `memory` is already MiB for both LXC and QEMU.
427
- Fall back to list fields (bytes) only when it is absent/zero.
428
- """
429
- mem_cfg = _safe_float(cfg.get("memory"))
430
- if mem_cfg:
431
- return mem_cfg
432
- for candidate in (entry.get("maxmem"), entry.get("mem")):
447
+ for candidate in (cfg.get("memory"), entry.get("maxmem"), entry.get("mem")):
433
448
  ram = _to_mib(candidate)
434
449
  if ram:
435
450
  return ram
@@ -583,10 +598,76 @@ def _ensure_containers_dir() -> None:
583
598
  CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
584
599
 
585
600
 
586
- def _invalidate_managed_containers_cache() -> None:
587
- with _MANAGED_CONTAINERS_CACHE_LOCK:
588
- _MANAGED_CONTAINERS_CACHE["timestamp"] = 0.0
589
- _MANAGED_CONTAINERS_CACHE["summary"] = None
601
+ def _copy_summary(summary: Dict[str, Any]) -> Dict[str, Any]:
602
+ snapshot = summary.copy()
603
+ containers = summary.get("containers")
604
+ if isinstance(containers, list):
605
+ snapshot["containers"] = [entry.copy() for entry in containers]
606
+ unmanaged = summary.get("unmanaged_containers")
607
+ if isinstance(unmanaged, list):
608
+ snapshot["unmanaged_containers"] = [entry.copy() for entry in unmanaged]
609
+ return snapshot
610
+
611
+
612
+ def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
613
+ if not records or not config:
614
+ return
615
+ try:
616
+ proxmox = _connect_proxmox(config)
617
+ node = _get_node_from_config(config)
618
+ statuses = {
619
+ str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
620
+ for ct in proxmox.nodes(node).lxc.get()
621
+ }
622
+ except Exception as exc: # pragma: no cover - best effort
623
+ logger.debug("Failed to refresh container statuses: %s", exc)
624
+ return
625
+ for record in records:
626
+ vmid = record.get("vmid")
627
+ if vmid is None:
628
+ continue
629
+ try:
630
+ vmid_key = str(int(vmid))
631
+ except (ValueError, TypeError):
632
+ continue
633
+ status = statuses.get(vmid_key)
634
+ if status:
635
+ record["status"] = status
636
+
637
+
638
+ def _initialize_managed_containers_state(force: bool = False) -> Dict[str, Any]:
639
+ with _MANAGED_CONTAINERS_STATE_LOCK:
640
+ if _MANAGED_CONTAINERS_STATE["initialized"] and not force:
641
+ base = _MANAGED_CONTAINERS_STATE["base_summary"]
642
+ return _copy_summary(base) if base else {}
643
+
644
+ config = _load_config()
645
+ records = _load_managed_container_records()
646
+ _refresh_container_statuses(records, config)
647
+ base_summary = _build_full_container_summary(records, config)
648
+ record_map: Dict[str, Dict[str, Any]] = {}
649
+ for record in records:
650
+ vmid = record.get("vmid")
651
+ if vmid is None:
652
+ continue
653
+ try:
654
+ record_map[str(int(vmid))] = record
655
+ except (TypeError, ValueError):
656
+ continue
657
+
658
+ initial_totals = {
659
+ "ram_mib": int(base_summary.get("total_ram_mib") or 0),
660
+ "disk_gib": int(base_summary.get("total_disk_gib") or 0),
661
+ "cpu_share": float(base_summary.get("total_cpu_share") or 0.0),
662
+ }
663
+
664
+ with _MANAGED_CONTAINERS_STATE_LOCK:
665
+ _MANAGED_CONTAINERS_STATE["initialized"] = True
666
+ _MANAGED_CONTAINERS_STATE["base_summary"] = base_summary
667
+ _MANAGED_CONTAINERS_STATE["initial_totals"] = initial_totals
668
+ _MANAGED_CONTAINERS_STATE["records"] = record_map
669
+ _MANAGED_CONTAINERS_STATE["pending"] = {}
670
+ return _copy_summary(base_summary)
590
671
 
591
672
 
592
673
  def _load_managed_container_records() -> List[Dict[str, Any]]:
@@ -729,13 +810,6 @@ def _build_full_container_summary(records: List[Dict[str, Any]], config: Dict[st
729
810
  "template": record.get("template") if record else None,
730
811
  "created_at": record.get("created_at") if record else None,
731
812
  }
732
- # Fallback to recorded specs if live probing returned zeros (e.g., just-created CT).
733
- if not merged["ram_mib"] and record:
734
- merged["ram_mib"] = record.get("ram_mib") or merged["ram_mib"]
735
- if not merged["disk_gib"] and record:
736
- merged["disk_gib"] = record.get("disk_gib") or merged["disk_gib"]
737
- if not merged["cpu_share"] and record:
738
- merged["cpu_share"] = record.get("cpus") or merged["cpu_share"]
739
813
  managed_entries.append(merged)
740
814
  else:
741
815
  unmanaged_entries.append(base_entry)
@@ -793,101 +867,131 @@ def _build_full_container_summary(records: List[Dict[str, Any]], config: Dict[st
793
867
  return summary
794
868
 
795
869
 
796
- def _compute_free_resources(summary: Dict[str, Any]) -> Dict[str, float]:
797
- """Return free resources using the same math as the dashboard."""
798
- free_ram = None
799
- free_disk = None
800
- free_cpu = None
801
- with _CAPACITY_LOCK:
802
- pending_ram = float(_PENDING_ALLOCATIONS["ram_mib"])
803
- pending_disk = float(_PENDING_ALLOCATIONS["disk_gib"])
804
- pending_cpu = float(_PENDING_ALLOCATIONS["cpu_share"])
805
- host_ram = summary.get("host_total_ram_mib")
806
- alloc_ram = summary.get("allocated_ram_mib")
807
- if host_ram is not None and alloc_ram is not None:
808
- free_ram = max(float(host_ram) - float(alloc_ram) - pending_ram, 0.0)
809
-
810
- host_disk = summary.get("host_total_disk_gib")
811
- alloc_disk = summary.get("allocated_disk_gib")
812
- if host_disk is not None and alloc_disk is not None:
813
- free_disk = max(float(host_disk) - float(alloc_disk) - pending_disk, 0.0)
814
-
815
- host_cpu = summary.get("host_total_cpu_cores")
816
- alloc_cpu = summary.get("allocated_cpu_share")
817
- if host_cpu is not None and alloc_cpu is not None:
818
- free_cpu = max(float(host_cpu) - float(alloc_cpu) - pending_cpu, 0.0)
819
-
820
- return {
821
- "ram_mib": free_ram,
822
- "disk_gib": free_disk,
823
- "cpu_share": free_cpu,
870
+ def _pending_totals(pending: Iterable[Dict[str, Any]]) -> Tuple[int, int, float]:
871
+ ram_total = 0
872
+ disk_total = 0
873
+ cpu_total = 0.0
874
+ for entry in pending:
875
+ ram_total += int(entry.get("ram_mib") or 0)
876
+ disk_total += int(entry.get("disk_gib") or 0)
877
+ cpu_total += float(entry.get("cpu_share") or 0.0)
878
+ return ram_total, disk_total, cpu_total
879
+
880
+
881
+ def _compose_managed_containers_summary(
882
+ records: Iterable[Dict[str, Any]],
883
+ pending: Iterable[Dict[str, Any]],
884
+ base_summary: Dict[str, Any],
885
+ initial_totals: Dict[str, Any],
886
+ ) -> Dict[str, Any]:
887
+ managed_summary = _build_managed_containers_summary(list(records))
888
+ summary = _copy_summary(base_summary)
889
+ base_by_vmid = {
890
+ str(entry.get("vmid")): entry
891
+ for entry in base_summary.get("containers", [])
892
+ if entry.get("vmid") is not None
824
893
  }
894
+ default_storage = summary.get("default_storage")
895
+ merged_containers: List[Dict[str, Any]] = []
896
+ for entry in managed_summary["containers"]:
897
+ vmid = str(entry.get("vmid")) if entry.get("vmid") is not None else None
898
+ base_entry = base_by_vmid.get(vmid) if vmid is not None else None
899
+ if base_entry:
900
+ merged = base_entry.copy()
901
+ merged.update(entry)
902
+ else:
903
+ merged = entry.copy()
904
+ merged.setdefault("managed", True)
905
+ merged.setdefault("type", "lxc")
906
+ if default_storage and merged.get("storage"):
907
+ merged["matches_default_storage"] = (
908
+ str(merged["storage"]).lower() == str(default_storage).lower()
909
+ )
910
+ merged_containers.append(merged)
911
+ summary["updated_at"] = managed_summary["updated_at"]
912
+ summary["count"] = managed_summary["count"]
913
+ summary["total_ram_mib"] = managed_summary["total_ram_mib"]
914
+ summary["total_disk_gib"] = managed_summary["total_disk_gib"]
915
+ summary["total_cpu_share"] = managed_summary["total_cpu_share"]
916
+ summary["containers"] = merged_containers
917
+
918
+ pending_ram, pending_disk, pending_cpu = _pending_totals(pending)
919
+ delta_ram = managed_summary["total_ram_mib"] - int(initial_totals.get("ram_mib") or 0)
920
+ delta_disk = managed_summary["total_disk_gib"] - int(initial_totals.get("disk_gib") or 0)
921
+ delta_cpu = managed_summary["total_cpu_share"] - float(initial_totals.get("cpu_share") or 0.0)
922
+
923
+ if "allocated_ram_mib" in summary and summary.get("allocated_ram_mib") is not None:
924
+ summary["allocated_ram_mib"] = round(float(summary["allocated_ram_mib"]) + delta_ram + pending_ram, 2)
925
+ if "allocated_disk_gib" in summary and summary.get("allocated_disk_gib") is not None:
926
+ summary["allocated_disk_gib"] = round(float(summary["allocated_disk_gib"]) + delta_disk + pending_disk, 2)
927
+ if "allocated_cpu_share" in summary and summary.get("allocated_cpu_share") is not None:
928
+ summary["allocated_cpu_share"] = round(float(summary["allocated_cpu_share"]) + delta_cpu + pending_cpu, 2)
929
+
930
+ if "available_ram_mib" in summary and summary.get("available_ram_mib") is not None:
931
+ available_ram = float(summary["available_ram_mib"]) - delta_ram - pending_ram
932
+ summary["available_ram_mib"] = max(int(available_ram), 0)
933
+ if "available_disk_gib" in summary and summary.get("available_disk_gib") is not None:
934
+ available_disk = float(summary["available_disk_gib"]) - delta_disk - pending_disk
935
+ summary["available_disk_gib"] = max(available_disk, 0.0)
936
+ if "available_cpu_share" in summary and summary.get("available_cpu_share") is not None:
937
+ available_cpu = float(summary["available_cpu_share"]) - delta_cpu - pending_cpu
938
+ summary["available_cpu_share"] = max(available_cpu, 0.0)
825
939
 
940
+ return summary
826
941
 
827
- def _assert_capacity_for_payload(payload: Dict[str, Any], summary: Dict[str, Any]) -> None:
828
- """Validate requested container resources against current free capacity."""
829
- free = _compute_free_resources(summary)
830
- shortages: List[str] = []
831
-
832
- req_ram = float(payload.get("ram_mib", 0))
833
- free_ram = free.get("ram_mib")
834
- if free_ram is not None and req_ram > free_ram:
835
- shortages.append(f"RAM (need {int(req_ram)} MiB, free {int(free_ram)} MiB)")
836
-
837
- req_disk = float(payload.get("disk_gib", 0))
838
- free_disk = free.get("disk_gib")
839
- if free_disk is not None and req_disk > free_disk:
840
- shortages.append(f"Disk (need {req_disk:.1f} GiB, free {free_disk:.1f} GiB)")
841
-
842
- req_cpu = float(payload.get("cpus", 0))
843
- free_cpu = free.get("cpu_share")
844
- if free_cpu is not None and req_cpu > free_cpu:
845
- shortages.append(f"CPU (need {req_cpu:.2f} vCPU, free {free_cpu:.2f} vCPU)")
846
942
 
847
- if shortages:
848
- raise RuntimeError(f"Insufficient resources: {', '.join(shortages)}")
943
+ def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
944
+ _initialize_managed_containers_state(force=force)
945
+ with _MANAGED_CONTAINERS_STATE_LOCK:
946
+ base_summary = _MANAGED_CONTAINERS_STATE.get("base_summary") or {}
947
+ records = list(_MANAGED_CONTAINERS_STATE.get("records", {}).values())
948
+ pending = list(_MANAGED_CONTAINERS_STATE.get("pending", {}).values())
949
+ initial_totals = _MANAGED_CONTAINERS_STATE.get("initial_totals", {})
950
+ if not base_summary:
951
+ return {}
952
+ return _compose_managed_containers_summary(records, pending, base_summary, initial_totals)
953
+
954
+
955
+ def _reserve_container_resources(payload: Dict[str, Any], *, device_id: str, request_id: Optional[str]) -> str:
956
+ _initialize_managed_containers_state()
957
+ ram_mib = int(payload.get("ram_mib") or 0)
958
+ disk_gib = int(payload.get("disk_gib") or 0)
959
+ cpu_share = float(payload.get("cpus") or 0.0)
960
+ reservation_id = secrets.token_hex(8)
961
+ with _MANAGED_CONTAINERS_STATE_LOCK:
962
+ base_summary = _MANAGED_CONTAINERS_STATE.get("base_summary") or {}
963
+ records = list(_MANAGED_CONTAINERS_STATE.get("records", {}).values())
964
+ pending = list(_MANAGED_CONTAINERS_STATE.get("pending", {}).values())
965
+ initial_totals = _MANAGED_CONTAINERS_STATE.get("initial_totals", {})
966
+ summary = _compose_managed_containers_summary(records, pending, base_summary, initial_totals)
967
+
968
+ available_ram = summary.get("available_ram_mib")
969
+ if available_ram is not None and ram_mib > available_ram:
970
+ raise RuntimeError("Not enough RAM to create this container.")
971
+ available_disk = summary.get("available_disk_gib")
972
+ if available_disk is not None and disk_gib > available_disk:
973
+ raise RuntimeError("Not enough disk space to create this container.")
974
+ available_cpu = summary.get("available_cpu_share")
975
+ if available_cpu is not None and cpu_share > available_cpu:
976
+ raise RuntimeError("Not enough CPU capacity to create this container.")
977
+
978
+ _MANAGED_CONTAINERS_STATE["pending"][reservation_id] = {
979
+ "reservation_id": reservation_id,
980
+ "device_id": device_id,
981
+ "request_id": request_id,
982
+ "ram_mib": ram_mib,
983
+ "disk_gib": disk_gib,
984
+ "cpu_share": cpu_share,
985
+ "created_at": datetime.utcnow().isoformat() + "Z",
986
+ }
987
+ return reservation_id
849
988
 
850
989
 
851
- def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
852
- def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
853
- if not records or not config:
854
- return
855
- try:
856
- proxmox = _connect_proxmox(config)
857
- node = _get_node_from_config(config)
858
- statuses = {
859
- str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
860
- for ct in proxmox.nodes(node).lxc.get()
861
- }
862
- except Exception as exc: # pragma: no cover - best effort
863
- logger.debug("Failed to refresh container statuses: %s", exc)
864
- return
865
- for record in records:
866
- vmid = record.get("vmid")
867
- if vmid is None:
868
- continue
869
- try:
870
- vmid_key = str(int(vmid))
871
- except (ValueError, TypeError):
872
- continue
873
- status = statuses.get(vmid_key)
874
- if status:
875
- record["status"] = status
876
-
877
- now = time.monotonic()
878
- with _MANAGED_CONTAINERS_CACHE_LOCK:
879
- cache_ts = _MANAGED_CONTAINERS_CACHE["timestamp"]
880
- cached = _MANAGED_CONTAINERS_CACHE["summary"]
881
- if not force and cached and now - cache_ts < _MANAGED_CONTAINERS_CACHE_TTL_S:
882
- return cached
883
- config = _load_config()
884
- records = _load_managed_container_records()
885
- _refresh_container_statuses(records, config)
886
- summary = _build_full_container_summary(records, config)
887
- with _MANAGED_CONTAINERS_CACHE_LOCK:
888
- _MANAGED_CONTAINERS_CACHE["timestamp"] = now
889
- _MANAGED_CONTAINERS_CACHE["summary"] = summary
890
- return summary
990
+ def _release_container_reservation(reservation_id: Optional[str]) -> None:
991
+ if not reservation_id:
992
+ return
993
+ with _MANAGED_CONTAINERS_STATE_LOCK:
994
+ _MANAGED_CONTAINERS_STATE.get("pending", {}).pop(reservation_id, None)
891
995
 
892
996
 
893
997
  def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
@@ -1173,31 +1277,8 @@ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any]
1173
1277
  logger.info("Container %s already running (%ss)", vmid, uptime)
1174
1278
  return status, 0.0
1175
1279
 
1176
- # Validate capacity using the same math as the dashboard and serialize allocation.
1177
- summary = _get_managed_containers_summary(force=True)
1178
- cfg = proxmox.nodes(node).lxc(vmid).config.get()
1179
- payload = {
1180
- "ram_mib": _to_mib(cfg.get("memory")),
1181
- "disk_gib": _pick_container_disk_gib("lxc", cfg, {"rootfs": cfg.get("rootfs")}),
1182
- "cpus": _pick_container_cpu_share("lxc", cfg, {}),
1183
- }
1184
- with _CAPACITY_LOCK:
1185
- try:
1186
- _assert_capacity_for_payload(payload, summary)
1187
- except RuntimeError as exc:
1188
- raise RuntimeError(f"Not enough resources to start container {vmid}: {exc}") from exc
1189
- _PENDING_ALLOCATIONS["ram_mib"] += float(payload["ram_mib"] or 0)
1190
- _PENDING_ALLOCATIONS["disk_gib"] += float(payload["disk_gib"] or 0)
1191
- _PENDING_ALLOCATIONS["cpu_share"] += float(payload["cpus"] or 0)
1192
-
1193
- try:
1194
- upid = proxmox.nodes(node).lxc(vmid).status.start.post()
1195
- return _wait_for_task(proxmox, node, upid)
1196
- finally:
1197
- with _CAPACITY_LOCK:
1198
- _PENDING_ALLOCATIONS["ram_mib"] -= float(payload["ram_mib"] or 0)
1199
- _PENDING_ALLOCATIONS["disk_gib"] -= float(payload["disk_gib"] or 0)
1200
- _PENDING_ALLOCATIONS["cpu_share"] -= float(payload["cpus"] or 0)
1280
+ upid = proxmox.nodes(node).lxc(vmid).status.start.post()
1281
+ return _wait_for_task(proxmox, node, upid)
1201
1282
 
1202
1283
 
1203
1284
  def _stop_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
@@ -1213,11 +1294,25 @@ def _delete_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any
1213
1294
  return _wait_for_task(proxmox, node, upid)
1214
1295
 
1215
1296
 
1216
- def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
1297
+ def _register_container_record(vmid: int, payload: Dict[str, Any], reservation_id: Optional[str] = None) -> None:
1298
+ _initialize_managed_containers_state()
1299
+ with _MANAGED_CONTAINERS_STATE_LOCK:
1300
+ if reservation_id:
1301
+ _MANAGED_CONTAINERS_STATE["pending"].pop(reservation_id, None)
1302
+ _MANAGED_CONTAINERS_STATE["records"][str(vmid)] = payload.copy()
1303
+
1304
+
1305
+ def _unregister_container_record(vmid: int) -> None:
1306
+ _initialize_managed_containers_state()
1307
+ with _MANAGED_CONTAINERS_STATE_LOCK:
1308
+ _MANAGED_CONTAINERS_STATE["records"].pop(str(vmid), None)
1309
+
1310
+
1311
+ def _write_container_record(vmid: int, payload: Dict[str, Any], reservation_id: Optional[str] = None) -> None:
1217
1312
  _ensure_containers_dir()
1218
1313
  path = CONTAINERS_DIR / f"ct-{vmid}.json"
1219
1314
  path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
1220
- _invalidate_managed_containers_cache()
1315
+ _register_container_record(vmid, payload, reservation_id=reservation_id)
1221
1316
 
1222
1317
 
1223
1318
  def _read_container_record(vmid: int) -> Dict[str, Any]:
@@ -1237,7 +1332,7 @@ def _remove_container_record(vmid: int) -> None:
1237
1332
  path = CONTAINERS_DIR / f"ct-{vmid}.json"
1238
1333
  if path.exists():
1239
1334
  path.unlink()
1240
- _invalidate_managed_containers_cache()
1335
+ _unregister_container_record(vmid)
1241
1336
 
1242
1337
 
1243
1338
  def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
@@ -1785,12 +1880,14 @@ def _allocate_vmid(proxmox: Any) -> int:
1785
1880
  return int(proxmox.cluster.nextid.get())
1786
1881
 
1787
1882
 
1788
- def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) -> Tuple[int, float]:
1883
+ def _instantiate_container(
1884
+ proxmox: Any, node: str, payload: Dict[str, Any], vmid: Optional[int] = None
1885
+ ) -> Tuple[int, float]:
1789
1886
  from proxmoxer.core import ResourceException
1790
1887
 
1791
1888
  storage_type = _get_storage_type(proxmox.nodes(node).storage.get(), payload["storage"])
1792
1889
  rootfs = _format_rootfs(payload["storage"], payload["disk_gib"], storage_type)
1793
- vmid = _allocate_vmid(proxmox)
1890
+ vmid = vmid or _allocate_vmid(proxmox)
1794
1891
  if not payload.get("hostname"):
1795
1892
  payload["hostname"] = f"ct{vmid}"
1796
1893
  try:
@@ -1822,6 +1919,43 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
1822
1919
  raise RuntimeError(f"Failed to create container: {exc}") from exc
1823
1920
 
1824
1921
 
1922
+ def _cleanup_failed_container(
1923
+ proxmox: Any, node: str, vmid: int, provisioning_id: Optional[str]
1924
+ ) -> None:
1925
+ from proxmoxer.core import ResourceException
1926
+
1927
+ try:
1928
+ cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
1929
+ except ResourceException as exc:
1930
+ msg = str(exc).lower()
1931
+ if "does not exist" in msg or "not found" in msg:
1932
+ return
1933
+ logger.warning("Failed to inspect container %s after create failure: %s", vmid, exc)
1934
+ return
1935
+ except Exception as exc: # pragma: no cover - best effort
1936
+ logger.warning("Failed to inspect container %s after create failure: %s", vmid, exc)
1937
+ return
1938
+
1939
+ description = (cfg or {}).get("description") or ""
1940
+ if provisioning_id and provisioning_id not in description:
1941
+ logger.warning(
1942
+ "Skipping cleanup for vmid=%s; provisioning marker mismatch", vmid
1943
+ )
1944
+ return
1945
+
1946
+ try:
1947
+ status = proxmox.nodes(node).lxc(str(vmid)).status.current.get()
1948
+ if status.get("status") == "running":
1949
+ _stop_container(proxmox, node, vmid)
1950
+ except Exception as exc: # pragma: no cover - best effort
1951
+ logger.warning("Failed to stop container %s after create failure: %s", vmid, exc)
1952
+
1953
+ try:
1954
+ _delete_container(proxmox, node, vmid)
1955
+ except Exception as exc: # pragma: no cover - best effort
1956
+ logger.warning("Failed to delete container %s after create failure: %s", vmid, exc)
1957
+
1958
+
1825
1959
  class CreateProxmoxContainerHandler(SyncHandler):
1826
1960
  """Provision a new managed LXC container via the Proxmox API."""
1827
1961
 
@@ -1924,7 +2058,15 @@ class CreateProxmoxContainerHandler(SyncHandler):
1924
2058
  _validate_environment,
1925
2059
  )
1926
2060
 
2061
+ reservation_id: Optional[str] = None
2062
+ provisioning_id: Optional[str] = None
2063
+ created_record = False
2064
+ proxmox: Any = None
2065
+ node: Optional[str] = None
2066
+ vmid: Optional[int] = None
2067
+
1927
2068
  def _create_container():
2069
+ nonlocal reservation_id, provisioning_id, created_record, proxmox, node, vmid
1928
2070
  proxmox = _connect_proxmox(config)
1929
2071
  node = config.get("node") or DEFAULT_NODE_NAME
1930
2072
  payload = _build_container_payload(message, config)
@@ -1932,23 +2074,21 @@ class CreateProxmoxContainerHandler(SyncHandler):
1932
2074
  payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
1933
2075
  payload["memory"] = int(payload["ram_mib"])
1934
2076
  payload["node"] = node
1935
- # Validate against current free resources (same math as dashboard charts) and place a short-lived reservation.
1936
- summary = _get_managed_containers_summary(force=True)
1937
- with _CAPACITY_LOCK:
1938
- try:
1939
- _assert_capacity_for_payload(payload, summary)
1940
- except RuntimeError as exc:
1941
- raise RuntimeError(f"Not enough resources to create the container safely: {exc}") from exc
1942
- _PENDING_ALLOCATIONS["ram_mib"] += float(payload["ram_mib"] or 0)
1943
- _PENDING_ALLOCATIONS["disk_gib"] += float(payload["disk_gib"] or 0)
1944
- _PENDING_ALLOCATIONS["cpu_share"] += float(payload["cpus"] or 0)
1945
- try:
1946
- vmid, _ = _instantiate_container(proxmox, node, payload)
1947
- finally:
1948
- with _CAPACITY_LOCK:
1949
- _PENDING_ALLOCATIONS["ram_mib"] -= float(payload["ram_mib"] or 0)
1950
- _PENDING_ALLOCATIONS["disk_gib"] -= float(payload["disk_gib"] or 0)
1951
- _PENDING_ALLOCATIONS["cpu_share"] -= float(payload["cpus"] or 0)
2077
+ reservation_id = _reserve_container_resources(
2078
+ payload, device_id=device_id, request_id=request_id
2079
+ )
2080
+ _emit_host_event(
2081
+ self,
2082
+ {
2083
+ "event": "proxmox_container_accepted",
2084
+ "success": True,
2085
+ "message": "Provisioning accepted; resources reserved.",
2086
+ "device_id": device_id,
2087
+ "request_id": request_id,
2088
+ },
2089
+ )
2090
+ provisioning_id = secrets.token_hex(6)
2091
+ payload["description"] = f"{payload.get('description', MANAGED_MARKER)};provisioning_id={provisioning_id}"
1952
2092
  logger.debug(
1953
2093
  "Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
1954
2094
  node,
@@ -1957,206 +2097,227 @@ class CreateProxmoxContainerHandler(SyncHandler):
1957
2097
  payload["cpus"],
1958
2098
  payload["storage"],
1959
2099
  )
2100
+ try:
2101
+ vmid = _allocate_vmid(proxmox)
2102
+ vmid, _ = _instantiate_container(proxmox, node, payload, vmid=vmid)
2103
+ except Exception:
2104
+ _release_container_reservation(reservation_id)
2105
+ if vmid is not None:
2106
+ _cleanup_failed_container(proxmox, node, vmid, provisioning_id)
2107
+ raise
1960
2108
  payload["vmid"] = vmid
1961
2109
  payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1962
2110
  payload["status"] = "creating"
1963
2111
  payload["device_id"] = device_id
1964
- _write_container_record(vmid, payload)
2112
+ try:
2113
+ _write_container_record(vmid, payload, reservation_id=reservation_id)
2114
+ created_record = True
2115
+ except Exception:
2116
+ _release_container_reservation(reservation_id)
2117
+ _cleanup_failed_container(proxmox, node, vmid, provisioning_id)
2118
+ raise
1965
2119
  return proxmox, node, vmid, payload
1966
2120
 
1967
- proxmox, node, vmid, payload = _run_lifecycle_step(
1968
- "create_container",
1969
- "Creating container",
1970
- "Provisioning the LXC container",
1971
- "Container created.",
1972
- _create_container,
1973
- )
1974
-
1975
- def _start_container_step():
1976
- _start_container(proxmox, node, vmid)
1977
-
1978
- _run_lifecycle_step(
1979
- "start_container",
1980
- "Starting container",
1981
- "Booting the container…",
1982
- "Container startup completed.",
1983
- _start_container_step,
1984
- )
1985
- _update_container_record(vmid, {"status": "running"})
1986
-
1987
- def _bootstrap_progress_callback(
1988
- step_index: int,
1989
- total: int,
1990
- step: Dict[str, Any],
1991
- status: str,
1992
- result: Optional[Dict[str, Any]],
1993
- ):
1994
- label = step.get("display_name") or _friendly_step_label(step.get("name", "bootstrap"))
1995
- error_summary = (result or {}).get("error_summary") or (result or {}).get("error")
1996
- attempt = (result or {}).get("attempt")
1997
- if status == "in_progress":
1998
- message_text = f"{label} is running…"
1999
- elif status == "completed":
2000
- message_text = f"{label} completed."
2001
- elif status == "retrying":
2002
- attempt_desc = f" (attempt {attempt})" if attempt else ""
2003
- message_text = f"{label} failed{attempt_desc}; retrying…"
2004
- else:
2005
- message_text = f"{label} failed"
2006
- if error_summary:
2007
- message_text += f": {error_summary}"
2008
- details: Dict[str, Any] = {}
2009
- if attempt:
2010
- details["attempt"] = attempt
2011
- if error_summary:
2012
- details["error_summary"] = error_summary
2013
- _emit_progress_event(
2014
- self,
2015
- step_index=step_index,
2016
- total_steps=total,
2017
- step_name=step.get("name", "bootstrap"),
2018
- step_label=label,
2019
- status=status,
2020
- message=message_text,
2021
- phase="bootstrap",
2022
- request_id=request_id,
2023
- details=details or None,
2024
- on_behalf_of_device=device_id,
2121
+ try:
2122
+ proxmox, node, vmid, payload = _run_lifecycle_step(
2123
+ "create_container",
2124
+ "Creating container",
2125
+ "Provisioning the LXC container…",
2126
+ "Container created.",
2127
+ _create_container,
2025
2128
  )
2026
2129
 
2027
- public_key, steps = _bootstrap_portacode(
2028
- vmid,
2029
- payload["username"],
2030
- payload["password"],
2031
- payload["ssh_public_key"],
2032
- steps=bootstrap_steps,
2033
- progress_callback=_bootstrap_progress_callback,
2034
- start_index=current_step_index,
2035
- total_steps=total_steps,
2036
- default_public_key=device_public_key if has_device_keypair else None,
2037
- )
2038
- current_step_index += len(bootstrap_steps)
2130
+ def _start_container_step():
2131
+ _start_container(proxmox, node, vmid)
2039
2132
 
2040
- service_installed = False
2041
- if has_device_keypair:
2042
- logger.info(
2043
- "deploying dashboard-provided Portacode keypair (device_id=%s) into container %s",
2044
- device_id,
2045
- vmid,
2133
+ _run_lifecycle_step(
2134
+ "start_container",
2135
+ "Starting container",
2136
+ "Booting the container",
2137
+ "Container startup completed.",
2138
+ _start_container_step,
2046
2139
  )
2047
- _deploy_device_keypair(
2140
+ _update_container_record(vmid, {"status": "running"})
2141
+
2142
+ def _bootstrap_progress_callback(
2143
+ step_index: int,
2144
+ total: int,
2145
+ step: Dict[str, Any],
2146
+ status: str,
2147
+ result: Optional[Dict[str, Any]],
2148
+ ):
2149
+ label = step.get("display_name") or _friendly_step_label(step.get("name", "bootstrap"))
2150
+ error_summary = (result or {}).get("error_summary") or (result or {}).get("error")
2151
+ attempt = (result or {}).get("attempt")
2152
+ if status == "in_progress":
2153
+ message_text = f"{label} is running…"
2154
+ elif status == "completed":
2155
+ message_text = f"{label} completed."
2156
+ elif status == "retrying":
2157
+ attempt_desc = f" (attempt {attempt})" if attempt else ""
2158
+ message_text = f"{label} failed{attempt_desc}; retrying…"
2159
+ else:
2160
+ message_text = f"{label} failed"
2161
+ if error_summary:
2162
+ message_text += f": {error_summary}"
2163
+ details: Dict[str, Any] = {}
2164
+ if attempt:
2165
+ details["attempt"] = attempt
2166
+ if error_summary:
2167
+ details["error_summary"] = error_summary
2168
+ _emit_progress_event(
2169
+ self,
2170
+ step_index=step_index,
2171
+ total_steps=total,
2172
+ step_name=step.get("name", "bootstrap"),
2173
+ step_label=label,
2174
+ status=status,
2175
+ message=message_text,
2176
+ phase="bootstrap",
2177
+ request_id=request_id,
2178
+ details=details or None,
2179
+ on_behalf_of_device=device_id,
2180
+ )
2181
+
2182
+ public_key, steps = _bootstrap_portacode(
2048
2183
  vmid,
2049
2184
  payload["username"],
2050
- device_private_key,
2051
- device_public_key,
2052
- )
2053
- service_installed = True
2054
- service_start_index = current_step_index
2055
-
2056
- auth_step_name = "setup_device_authentication"
2057
- auth_label = "Setting up device authentication"
2058
- _emit_progress_event(
2059
- self,
2060
- step_index=service_start_index,
2061
- total_steps=total_steps,
2062
- step_name=auth_step_name,
2063
- step_label=auth_label,
2064
- status="in_progress",
2065
- message="Notifying the server of the new device…",
2066
- phase="service",
2067
- request_id=request_id,
2068
- on_behalf_of_device=device_id,
2069
- )
2070
- _emit_progress_event(
2071
- self,
2072
- step_index=service_start_index,
2185
+ payload["password"],
2186
+ payload["ssh_public_key"],
2187
+ steps=bootstrap_steps,
2188
+ progress_callback=_bootstrap_progress_callback,
2189
+ start_index=current_step_index,
2073
2190
  total_steps=total_steps,
2074
- step_name=auth_step_name,
2075
- step_label=auth_label,
2076
- status="completed",
2077
- message="Authentication metadata recorded.",
2078
- phase="service",
2079
- request_id=request_id,
2080
- on_behalf_of_device=device_id,
2081
- )
2082
-
2083
- install_step = service_start_index + 1
2084
- install_label = "Launching Portacode service"
2085
- _emit_progress_event(
2086
- self,
2087
- step_index=install_step,
2088
- total_steps=total_steps,
2089
- step_name="launch_portacode_service",
2090
- step_label=install_label,
2091
- status="in_progress",
2092
- message="Running sudo portacode service install…",
2093
- phase="service",
2094
- request_id=request_id,
2095
- on_behalf_of_device=device_id,
2191
+ default_public_key=device_public_key if has_device_keypair else None,
2096
2192
  )
2193
+ current_step_index += len(bootstrap_steps)
2194
+
2195
+ service_installed = False
2196
+ if has_device_keypair:
2197
+ logger.info(
2198
+ "deploying dashboard-provided Portacode keypair (device_id=%s) into container %s",
2199
+ device_id,
2200
+ vmid,
2201
+ )
2202
+ _deploy_device_keypair(
2203
+ vmid,
2204
+ payload["username"],
2205
+ device_private_key,
2206
+ device_public_key,
2207
+ )
2208
+ service_installed = True
2209
+ service_start_index = current_step_index
2097
2210
 
2098
- cmd = _su_command(payload["username"], "sudo -S portacode service install")
2099
- res = _run_pct(vmid, cmd, input_text=payload["password"] + "\n")
2211
+ auth_step_name = "setup_device_authentication"
2212
+ auth_label = "Setting up device authentication"
2213
+ _emit_progress_event(
2214
+ self,
2215
+ step_index=service_start_index,
2216
+ total_steps=total_steps,
2217
+ step_name=auth_step_name,
2218
+ step_label=auth_label,
2219
+ status="in_progress",
2220
+ message="Notifying the server of the new device…",
2221
+ phase="service",
2222
+ request_id=request_id,
2223
+ on_behalf_of_device=device_id,
2224
+ )
2225
+ _emit_progress_event(
2226
+ self,
2227
+ step_index=service_start_index,
2228
+ total_steps=total_steps,
2229
+ step_name=auth_step_name,
2230
+ step_label=auth_label,
2231
+ status="completed",
2232
+ message="Authentication metadata recorded.",
2233
+ phase="service",
2234
+ request_id=request_id,
2235
+ on_behalf_of_device=device_id,
2236
+ )
2100
2237
 
2101
- if res["returncode"] != 0:
2238
+ install_step = service_start_index + 1
2239
+ install_label = "Launching Portacode service"
2102
2240
  _emit_progress_event(
2103
2241
  self,
2104
2242
  step_index=install_step,
2105
2243
  total_steps=total_steps,
2106
2244
  step_name="launch_portacode_service",
2107
2245
  step_label=install_label,
2108
- status="failed",
2109
- message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
2246
+ status="in_progress",
2247
+ message="Running sudo portacode service install…",
2110
2248
  phase="service",
2111
2249
  request_id=request_id,
2112
- details={
2113
- "stderr": res.get("stderr"),
2114
- "stdout": res.get("stdout"),
2115
- },
2116
2250
  on_behalf_of_device=device_id,
2117
2251
  )
2118
- raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
2119
2252
 
2120
- _emit_progress_event(
2121
- self,
2122
- step_index=install_step,
2123
- total_steps=total_steps,
2124
- step_name="launch_portacode_service",
2125
- step_label=install_label,
2126
- status="completed",
2127
- message="Portacode service install finished.",
2128
- phase="service",
2129
- request_id=request_id,
2130
- on_behalf_of_device=device_id,
2131
- )
2253
+ cmd = _su_command(payload["username"], "sudo -S portacode service install")
2254
+ res = _run_pct(vmid, cmd, input_text=payload["password"] + "\n")
2255
+
2256
+ if res["returncode"] != 0:
2257
+ _emit_progress_event(
2258
+ self,
2259
+ step_index=install_step,
2260
+ total_steps=total_steps,
2261
+ step_name="launch_portacode_service",
2262
+ step_label=install_label,
2263
+ status="failed",
2264
+ message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
2265
+ phase="service",
2266
+ request_id=request_id,
2267
+ details={
2268
+ "stderr": res.get("stderr"),
2269
+ "stdout": res.get("stdout"),
2270
+ },
2271
+ on_behalf_of_device=device_id,
2272
+ )
2273
+ raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
2132
2274
 
2133
- logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
2275
+ _emit_progress_event(
2276
+ self,
2277
+ step_index=install_step,
2278
+ total_steps=total_steps,
2279
+ step_name="launch_portacode_service",
2280
+ step_label=install_label,
2281
+ status="completed",
2282
+ message="Portacode service install finished.",
2283
+ phase="service",
2284
+ request_id=request_id,
2285
+ on_behalf_of_device=device_id,
2286
+ )
2134
2287
 
2135
- current_step_index += 2
2288
+ logger.info(
2289
+ "create_proxmox_container: portacode service install completed inside ct %s", vmid
2290
+ )
2136
2291
 
2137
- response = {
2138
- "event": "proxmox_container_created",
2139
- "success": True,
2140
- "message": f"Container {vmid} is ready and Portacode key captured.",
2141
- "ctid": str(vmid),
2142
- "public_key": public_key,
2143
- "container": {
2144
- "vmid": vmid,
2145
- "hostname": payload["hostname"],
2146
- "template": payload["template"],
2147
- "storage": payload["storage"],
2148
- "disk_gib": payload["disk_gib"],
2149
- "ram_mib": payload["ram_mib"],
2150
- "cpus": payload["cpus"],
2151
- },
2152
- "setup_steps": steps,
2153
- "device_id": device_id,
2154
- "on_behalf_of_device": device_id,
2155
- "service_installed": service_installed,
2156
- }
2157
- if not response:
2158
- raise RuntimeError("create_proxmox_container produced no response payload")
2159
- return response
2292
+ current_step_index += 2
2293
+
2294
+ return {
2295
+ "event": "proxmox_container_created",
2296
+ "success": True,
2297
+ "message": f"Container {vmid} is ready and Portacode key captured.",
2298
+ "ctid": str(vmid),
2299
+ "public_key": public_key,
2300
+ "container": {
2301
+ "vmid": vmid,
2302
+ "hostname": payload["hostname"],
2303
+ "template": payload["template"],
2304
+ "storage": payload["storage"],
2305
+ "disk_gib": payload["disk_gib"],
2306
+ "ram_mib": payload["ram_mib"],
2307
+ "cpus": payload["cpus"],
2308
+ },
2309
+ "setup_steps": steps,
2310
+ "device_id": device_id,
2311
+ "on_behalf_of_device": device_id,
2312
+ "service_installed": service_installed,
2313
+ }
2314
+ except Exception:
2315
+ if reservation_id and not created_record:
2316
+ _release_container_reservation(reservation_id)
2317
+ if vmid is not None and proxmox and node:
2318
+ _cleanup_failed_container(proxmox, node, vmid, provisioning_id)
2319
+ _remove_container_record(vmid)
2320
+ raise
2160
2321
 
2161
2322
 
2162
2323
  class StartPortacodeServiceHandler(SyncHandler):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.17.dev6
3
+ Version: 1.4.17.dev7
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=BieguAdHtQb8jxJDq07EaNCiTUct5ZU-e1HrKSeSOHQ,719
4
+ portacode/_version.py,sha256=Inn3jAXtKmJcxP1Ht9pcGtSS3m4h3OsmOpgsOB9feYM,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
@@ -22,7 +22,7 @@ portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZb
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=nOKhFs7JgpYVIfZs6xSaIf7qFVCX3B4BACrrXBXO8nQ,89884
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=hsEsulBU55bLF5j5ACDLo-51hZe8rMKj5bdB5n5jv9Y,97125
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=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
@@ -65,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
65
65
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
66
66
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
67
67
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
68
- portacode-1.4.17.dev6.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.17.dev7.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
69
69
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
70
70
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
71
71
  test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
@@ -91,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
91
91
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
92
92
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
93
93
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
94
- portacode-1.4.17.dev6.dist-info/METADATA,sha256=Q2yCzBl8-toCB8tJtzryZ878HCCyl6FaMqrRmvw9SNI,13051
95
- portacode-1.4.17.dev6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
- portacode-1.4.17.dev6.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
- portacode-1.4.17.dev6.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
- portacode-1.4.17.dev6.dist-info/RECORD,,
94
+ portacode-1.4.17.dev7.dist-info/METADATA,sha256=WSr8txw3OXCRLK9nD39AW20JtMkJUq_OjJ0xWv410f0,13051
95
+ portacode-1.4.17.dev7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
+ portacode-1.4.17.dev7.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.17.dev7.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.17.dev7.dist-info/RECORD,,