portacode 1.4.16.dev11__py3-none-any.whl → 1.4.17__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.16.dev11'
32
- __version_tuple__ = version_tuple = (1, 4, 16, 'dev11')
31
+ __version__ = version = '1.4.17'
32
+ __version_tuple__ = version_tuple = (1, 4, 17)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -45,14 +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()
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
+ }
51
56
  TEMPLATES_REFRESH_INTERVAL_S = 300
52
57
 
53
58
  ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
54
59
 
55
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
+
56
80
  def _emit_progress_event(
57
81
  handler: SyncHandler,
58
82
  *,
@@ -574,10 +598,76 @@ def _ensure_containers_dir() -> None:
574
598
  CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
575
599
 
576
600
 
577
- def _invalidate_managed_containers_cache() -> None:
578
- with _MANAGED_CONTAINERS_CACHE_LOCK:
579
- _MANAGED_CONTAINERS_CACHE["timestamp"] = 0.0
580
- _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)
581
671
 
582
672
 
583
673
  def _load_managed_container_records() -> List[Dict[str, Any]]:
@@ -777,48 +867,133 @@ def _build_full_container_summary(records: List[Dict[str, Any]], config: Dict[st
777
867
  return summary
778
868
 
779
869
 
780
- def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
781
- def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
782
- if not records or not config:
783
- return
784
- try:
785
- proxmox = _connect_proxmox(config)
786
- node = _get_node_from_config(config)
787
- statuses = {
788
- str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
789
- for ct in proxmox.nodes(node).lxc.get()
790
- }
791
- except Exception as exc: # pragma: no cover - best effort
792
- logger.debug("Failed to refresh container statuses: %s", exc)
793
- return
794
- for record in records:
795
- vmid = record.get("vmid")
796
- if vmid is None:
797
- continue
798
- try:
799
- vmid_key = str(int(vmid))
800
- except (ValueError, TypeError):
801
- continue
802
- status = statuses.get(vmid_key)
803
- if status:
804
- record["status"] = status
805
-
806
- now = time.monotonic()
807
- with _MANAGED_CONTAINERS_CACHE_LOCK:
808
- cache_ts = _MANAGED_CONTAINERS_CACHE["timestamp"]
809
- cached = _MANAGED_CONTAINERS_CACHE["summary"]
810
- if not force and cached and now - cache_ts < _MANAGED_CONTAINERS_CACHE_TTL_S:
811
- return cached
812
- config = _load_config()
813
- records = _load_managed_container_records()
814
- _refresh_container_statuses(records, config)
815
- summary = _build_full_container_summary(records, config)
816
- with _MANAGED_CONTAINERS_CACHE_LOCK:
817
- _MANAGED_CONTAINERS_CACHE["timestamp"] = now
818
- _MANAGED_CONTAINERS_CACHE["summary"] = summary
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
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)
939
+
819
940
  return summary
820
941
 
821
942
 
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
988
+
989
+
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)
995
+
996
+
822
997
  def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
823
998
  if storage_type in ("lvm", "lvmthin"):
824
999
  return f"{storage}:{disk_gib}"
@@ -1102,23 +1277,6 @@ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any]
1102
1277
  logger.info("Container %s already running (%ss)", vmid, uptime)
1103
1278
  return status, 0.0
1104
1279
 
1105
- node_status = proxmox.nodes(node).status.get()
1106
- mem_total_mb = int(node_status.get("memory", {}).get("total", 0) // (1024**2))
1107
- cores_total = int(node_status.get("cpuinfo", {}).get("cores", 0))
1108
-
1109
- running = _list_running_managed(proxmox, node)
1110
- used_mem_mb = sum(int(cfg.get("memory", 0)) for _, cfg in running)
1111
- used_cores = sum(int(cfg.get("cores", 0)) for _, cfg in running)
1112
-
1113
- target_cfg = proxmox.nodes(node).lxc(vmid).config.get()
1114
- target_mem_mb = int(target_cfg.get("memory", 0))
1115
- target_cores = int(target_cfg.get("cores", 0))
1116
-
1117
- if mem_total_mb and used_mem_mb + target_mem_mb > mem_total_mb:
1118
- raise RuntimeError("Not enough RAM to start this container safely.")
1119
- if cores_total and used_cores + target_cores > cores_total:
1120
- raise RuntimeError("Not enough CPU cores to start this container safely.")
1121
-
1122
1280
  upid = proxmox.nodes(node).lxc(vmid).status.start.post()
1123
1281
  return _wait_for_task(proxmox, node, upid)
1124
1282
 
@@ -1136,11 +1294,25 @@ def _delete_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any
1136
1294
  return _wait_for_task(proxmox, node, upid)
1137
1295
 
1138
1296
 
1139
- 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:
1140
1312
  _ensure_containers_dir()
1141
1313
  path = CONTAINERS_DIR / f"ct-{vmid}.json"
1142
1314
  path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
1143
- _invalidate_managed_containers_cache()
1315
+ _register_container_record(vmid, payload, reservation_id=reservation_id)
1144
1316
 
1145
1317
 
1146
1318
  def _read_container_record(vmid: int) -> Dict[str, Any]:
@@ -1160,7 +1332,7 @@ def _remove_container_record(vmid: int) -> None:
1160
1332
  path = CONTAINERS_DIR / f"ct-{vmid}.json"
1161
1333
  if path.exists():
1162
1334
  path.unlink()
1163
- _invalidate_managed_containers_cache()
1335
+ _unregister_container_record(vmid)
1164
1336
 
1165
1337
 
1166
1338
  def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
@@ -1266,6 +1438,15 @@ def _su_command(user: str, command: str) -> str:
1266
1438
  return f"su - {user} -s /bin/sh -c {shlex.quote(command)}"
1267
1439
 
1268
1440
 
1441
+ def _resolve_portacode_cli_path(vmid: int, user: str) -> str:
1442
+ """Resolve the full path to the portacode CLI inside the container."""
1443
+ res = _run_pct(vmid, _su_command(user, "command -v portacode"))
1444
+ path = (res.get("stdout") or "").strip()
1445
+ if path:
1446
+ return path
1447
+ return "portacode"
1448
+
1449
+
1269
1450
  def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
1270
1451
  res = _run_pct(vmid, cmd)
1271
1452
  if res["returncode"] != 0:
@@ -1708,12 +1889,14 @@ def _allocate_vmid(proxmox: Any) -> int:
1708
1889
  return int(proxmox.cluster.nextid.get())
1709
1890
 
1710
1891
 
1711
- def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) -> Tuple[int, float]:
1892
+ def _instantiate_container(
1893
+ proxmox: Any, node: str, payload: Dict[str, Any], vmid: Optional[int] = None
1894
+ ) -> Tuple[int, float]:
1712
1895
  from proxmoxer.core import ResourceException
1713
1896
 
1714
1897
  storage_type = _get_storage_type(proxmox.nodes(node).storage.get(), payload["storage"])
1715
1898
  rootfs = _format_rootfs(payload["storage"], payload["disk_gib"], storage_type)
1716
- vmid = _allocate_vmid(proxmox)
1899
+ vmid = vmid or _allocate_vmid(proxmox)
1717
1900
  if not payload.get("hostname"):
1718
1901
  payload["hostname"] = f"ct{vmid}"
1719
1902
  try:
@@ -1745,6 +1928,43 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
1745
1928
  raise RuntimeError(f"Failed to create container: {exc}") from exc
1746
1929
 
1747
1930
 
1931
+ def _cleanup_failed_container(
1932
+ proxmox: Any, node: str, vmid: int, provisioning_id: Optional[str]
1933
+ ) -> None:
1934
+ from proxmoxer.core import ResourceException
1935
+
1936
+ try:
1937
+ cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
1938
+ except ResourceException as exc:
1939
+ msg = str(exc).lower()
1940
+ if "does not exist" in msg or "not found" in msg:
1941
+ return
1942
+ logger.warning("Failed to inspect container %s after create failure: %s", vmid, exc)
1943
+ return
1944
+ except Exception as exc: # pragma: no cover - best effort
1945
+ logger.warning("Failed to inspect container %s after create failure: %s", vmid, exc)
1946
+ return
1947
+
1948
+ description = (cfg or {}).get("description") or ""
1949
+ if provisioning_id and provisioning_id not in description:
1950
+ logger.warning(
1951
+ "Skipping cleanup for vmid=%s; provisioning marker mismatch", vmid
1952
+ )
1953
+ return
1954
+
1955
+ try:
1956
+ status = proxmox.nodes(node).lxc(str(vmid)).status.current.get()
1957
+ if status.get("status") == "running":
1958
+ _stop_container(proxmox, node, vmid)
1959
+ except Exception as exc: # pragma: no cover - best effort
1960
+ logger.warning("Failed to stop container %s after create failure: %s", vmid, exc)
1961
+
1962
+ try:
1963
+ _delete_container(proxmox, node, vmid)
1964
+ except Exception as exc: # pragma: no cover - best effort
1965
+ logger.warning("Failed to delete container %s after create failure: %s", vmid, exc)
1966
+
1967
+
1748
1968
  class CreateProxmoxContainerHandler(SyncHandler):
1749
1969
  """Provision a new managed LXC container via the Proxmox API."""
1750
1970
 
@@ -1847,219 +2067,281 @@ class CreateProxmoxContainerHandler(SyncHandler):
1847
2067
  _validate_environment,
1848
2068
  )
1849
2069
 
1850
- def _create_container():
1851
- proxmox = _connect_proxmox(config)
1852
- node = config.get("node") or DEFAULT_NODE_NAME
1853
- payload = _build_container_payload(message, config)
1854
- payload["cpulimit"] = float(payload["cpus"])
1855
- payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
1856
- payload["memory"] = int(payload["ram_mib"])
1857
- payload["node"] = node
1858
- logger.debug(
1859
- "Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
1860
- node,
1861
- payload["template"],
1862
- payload["ram_mib"],
1863
- payload["cpus"],
1864
- payload["storage"],
1865
- )
1866
- vmid, _ = _instantiate_container(proxmox, node, payload)
1867
- payload["vmid"] = vmid
1868
- payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1869
- payload["status"] = "creating"
1870
- payload["device_id"] = device_id
1871
- _write_container_record(vmid, payload)
1872
- return proxmox, node, vmid, payload
1873
-
1874
- proxmox, node, vmid, payload = _run_lifecycle_step(
1875
- "create_container",
1876
- "Creating container",
1877
- "Provisioning the LXC container…",
1878
- "Container created.",
1879
- _create_container,
1880
- )
1881
-
1882
- def _start_container_step():
1883
- _start_container(proxmox, node, vmid)
2070
+ node = config.get("node") or DEFAULT_NODE_NAME
2071
+ payload = _build_container_payload(message, config)
2072
+ payload["cpulimit"] = float(payload["cpus"])
2073
+ payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
2074
+ payload["memory"] = int(payload["ram_mib"])
2075
+ payload["node"] = node
1884
2076
 
1885
- _run_lifecycle_step(
1886
- "start_container",
1887
- "Starting container",
1888
- "Booting the container…",
1889
- "Container startup completed.",
1890
- _start_container_step,
2077
+ reservation_id = _reserve_container_resources(
2078
+ payload, device_id=device_id, request_id=request_id
1891
2079
  )
1892
- _update_container_record(vmid, {"status": "running"})
2080
+ provisioning_id = secrets.token_hex(6)
2081
+ payload["description"] = f"{payload.get('description', MANAGED_MARKER)};provisioning_id={provisioning_id}"
1893
2082
 
1894
- def _bootstrap_progress_callback(
1895
- step_index: int,
1896
- total: int,
1897
- step: Dict[str, Any],
1898
- status: str,
1899
- result: Optional[Dict[str, Any]],
1900
- ):
1901
- label = step.get("display_name") or _friendly_step_label(step.get("name", "bootstrap"))
1902
- error_summary = (result or {}).get("error_summary") or (result or {}).get("error")
1903
- attempt = (result or {}).get("attempt")
1904
- if status == "in_progress":
1905
- message_text = f"{label} is running…"
1906
- elif status == "completed":
1907
- message_text = f"{label} completed."
1908
- elif status == "retrying":
1909
- attempt_desc = f" (attempt {attempt})" if attempt else ""
1910
- message_text = f"{label} failed{attempt_desc}; retrying…"
1911
- else:
1912
- message_text = f"{label} failed"
1913
- if error_summary:
1914
- message_text += f": {error_summary}"
1915
- details: Dict[str, Any] = {}
1916
- if attempt:
1917
- details["attempt"] = attempt
1918
- if error_summary:
1919
- details["error_summary"] = error_summary
1920
- _emit_progress_event(
1921
- self,
1922
- step_index=step_index,
1923
- total_steps=total,
1924
- step_name=step.get("name", "bootstrap"),
1925
- step_label=label,
1926
- status=status,
1927
- message=message_text,
1928
- phase="bootstrap",
1929
- request_id=request_id,
1930
- details=details or None,
1931
- on_behalf_of_device=device_id,
1932
- )
1933
-
1934
- public_key, steps = _bootstrap_portacode(
1935
- vmid,
1936
- payload["username"],
1937
- payload["password"],
1938
- payload["ssh_public_key"],
1939
- steps=bootstrap_steps,
1940
- progress_callback=_bootstrap_progress_callback,
1941
- start_index=current_step_index,
1942
- total_steps=total_steps,
1943
- default_public_key=device_public_key if has_device_keypair else None,
1944
- )
1945
- current_step_index += len(bootstrap_steps)
1946
-
1947
- service_installed = False
1948
- if has_device_keypair:
1949
- logger.info(
1950
- "deploying dashboard-provided Portacode keypair (device_id=%s) into container %s",
1951
- device_id,
1952
- vmid,
1953
- )
1954
- _deploy_device_keypair(
1955
- vmid,
1956
- payload["username"],
1957
- device_private_key,
1958
- device_public_key,
1959
- )
1960
- service_installed = True
1961
- service_start_index = current_step_index
1962
-
1963
- auth_step_name = "setup_device_authentication"
1964
- auth_label = "Setting up device authentication"
1965
- _emit_progress_event(
1966
- self,
1967
- step_index=service_start_index,
1968
- total_steps=total_steps,
1969
- step_name=auth_step_name,
1970
- step_label=auth_label,
1971
- status="in_progress",
1972
- message="Notifying the server of the new device…",
1973
- phase="service",
1974
- request_id=request_id,
1975
- on_behalf_of_device=device_id,
1976
- )
1977
- _emit_progress_event(
1978
- self,
1979
- step_index=service_start_index,
1980
- total_steps=total_steps,
1981
- step_name=auth_step_name,
1982
- step_label=auth_label,
1983
- status="completed",
1984
- message="Authentication metadata recorded.",
1985
- phase="service",
1986
- request_id=request_id,
1987
- on_behalf_of_device=device_id,
1988
- )
1989
-
1990
- install_step = service_start_index + 1
1991
- install_label = "Launching Portacode service"
1992
- _emit_progress_event(
1993
- self,
1994
- step_index=install_step,
1995
- total_steps=total_steps,
1996
- step_name="launch_portacode_service",
1997
- step_label=install_label,
1998
- status="in_progress",
1999
- message="Running sudo portacode service install…",
2000
- phase="service",
2001
- request_id=request_id,
2002
- on_behalf_of_device=device_id,
2003
- )
2083
+ def _provision_background() -> None:
2084
+ nonlocal current_step_index
2085
+ proxmox: Any = None
2086
+ vmid: Optional[int] = None
2087
+ created_record = False
2088
+ try:
2089
+ def _create_container():
2090
+ nonlocal proxmox, vmid, created_record
2091
+ proxmox = _connect_proxmox(config)
2092
+ logger.debug(
2093
+ "Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
2094
+ node,
2095
+ payload["template"],
2096
+ payload["ram_mib"],
2097
+ payload["cpus"],
2098
+ payload["storage"],
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
2108
+ payload["vmid"] = vmid
2109
+ payload["created_at"] = datetime.utcnow().isoformat() + "Z"
2110
+ payload["status"] = "creating"
2111
+ payload["device_id"] = device_id
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
2119
+ return proxmox, node, vmid, payload
2120
+
2121
+ proxmox, _, vmid, payload_local = _run_lifecycle_step(
2122
+ "create_container",
2123
+ "Creating container",
2124
+ "Provisioning the LXC container…",
2125
+ "Container created.",
2126
+ _create_container,
2127
+ )
2004
2128
 
2005
- cmd = _su_command(payload["username"], "sudo -S portacode service install")
2006
- res = _run_pct(vmid, cmd, input_text=payload["password"] + "\n")
2129
+ def _start_container_step():
2130
+ _start_container(proxmox, node, vmid)
2007
2131
 
2008
- if res["returncode"] != 0:
2009
- _emit_progress_event(
2010
- self,
2011
- step_index=install_step,
2132
+ _run_lifecycle_step(
2133
+ "start_container",
2134
+ "Starting container",
2135
+ "Booting the container…",
2136
+ "Container startup completed.",
2137
+ _start_container_step,
2138
+ )
2139
+ _update_container_record(vmid, {"status": "running"})
2140
+
2141
+ def _bootstrap_progress_callback(
2142
+ step_index: int,
2143
+ total: int,
2144
+ step: Dict[str, Any],
2145
+ status: str,
2146
+ result: Optional[Dict[str, Any]],
2147
+ ):
2148
+ label = step.get("display_name") or _friendly_step_label(step.get("name", "bootstrap"))
2149
+ error_summary = (result or {}).get("error_summary") or (result or {}).get("error")
2150
+ attempt = (result or {}).get("attempt")
2151
+ if status == "in_progress":
2152
+ message_text = f"{label} is running…"
2153
+ elif status == "completed":
2154
+ message_text = f"{label} completed."
2155
+ elif status == "retrying":
2156
+ attempt_desc = f" (attempt {attempt})" if attempt else ""
2157
+ message_text = f"{label} failed{attempt_desc}; retrying…"
2158
+ else:
2159
+ message_text = f"{label} failed"
2160
+ if error_summary:
2161
+ message_text += f": {error_summary}"
2162
+ details: Dict[str, Any] = {}
2163
+ if attempt:
2164
+ details["attempt"] = attempt
2165
+ if error_summary:
2166
+ details["error_summary"] = error_summary
2167
+ _emit_progress_event(
2168
+ self,
2169
+ step_index=step_index,
2170
+ total_steps=total,
2171
+ step_name=step.get("name", "bootstrap"),
2172
+ step_label=label,
2173
+ status=status,
2174
+ message=message_text,
2175
+ phase="bootstrap",
2176
+ request_id=request_id,
2177
+ details=details or None,
2178
+ on_behalf_of_device=device_id,
2179
+ )
2180
+
2181
+ public_key, steps = _bootstrap_portacode(
2182
+ vmid,
2183
+ payload_local["username"],
2184
+ payload_local["password"],
2185
+ payload_local["ssh_public_key"],
2186
+ steps=bootstrap_steps,
2187
+ progress_callback=_bootstrap_progress_callback,
2188
+ start_index=current_step_index,
2012
2189
  total_steps=total_steps,
2013
- step_name="launch_portacode_service",
2014
- step_label=install_label,
2015
- status="failed",
2016
- message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
2017
- phase="service",
2018
- request_id=request_id,
2019
- details={
2020
- "stderr": res.get("stderr"),
2021
- "stdout": res.get("stdout"),
2190
+ default_public_key=device_public_key if has_device_keypair else None,
2191
+ )
2192
+ current_step_index += len(bootstrap_steps)
2193
+
2194
+ service_installed = False
2195
+ if has_device_keypair:
2196
+ logger.info(
2197
+ "deploying dashboard-provided Portacode keypair (device_id=%s) into container %s",
2198
+ device_id,
2199
+ vmid,
2200
+ )
2201
+ _deploy_device_keypair(
2202
+ vmid,
2203
+ payload_local["username"],
2204
+ device_private_key,
2205
+ device_public_key,
2206
+ )
2207
+ service_installed = True
2208
+ service_start_index = current_step_index
2209
+
2210
+ auth_step_name = "setup_device_authentication"
2211
+ auth_label = "Setting up device authentication"
2212
+ _emit_progress_event(
2213
+ self,
2214
+ step_index=service_start_index,
2215
+ total_steps=total_steps,
2216
+ step_name=auth_step_name,
2217
+ step_label=auth_label,
2218
+ status="in_progress",
2219
+ message="Notifying the server of the new device…",
2220
+ phase="service",
2221
+ request_id=request_id,
2222
+ on_behalf_of_device=device_id,
2223
+ )
2224
+ _emit_progress_event(
2225
+ self,
2226
+ step_index=service_start_index,
2227
+ total_steps=total_steps,
2228
+ step_name=auth_step_name,
2229
+ step_label=auth_label,
2230
+ status="completed",
2231
+ message="Authentication metadata recorded.",
2232
+ phase="service",
2233
+ request_id=request_id,
2234
+ on_behalf_of_device=device_id,
2235
+ )
2236
+
2237
+ install_step = service_start_index + 1
2238
+ install_label = "Launching Portacode service"
2239
+ _emit_progress_event(
2240
+ self,
2241
+ step_index=install_step,
2242
+ total_steps=total_steps,
2243
+ step_name="launch_portacode_service",
2244
+ step_label=install_label,
2245
+ status="in_progress",
2246
+ message="Running sudo portacode service install…",
2247
+ phase="service",
2248
+ request_id=request_id,
2249
+ on_behalf_of_device=device_id,
2250
+ )
2251
+
2252
+ cli_path = _resolve_portacode_cli_path(vmid, payload_local["username"])
2253
+ cmd = _su_command(
2254
+ payload_local["username"],
2255
+ f"sudo -S {shlex.quote(cli_path)} service install",
2256
+ )
2257
+ res = _run_pct(vmid, cmd, input_text=payload_local["password"] + "\n")
2258
+
2259
+ if res["returncode"] != 0:
2260
+ _emit_progress_event(
2261
+ self,
2262
+ step_index=install_step,
2263
+ total_steps=total_steps,
2264
+ step_name="launch_portacode_service",
2265
+ step_label=install_label,
2266
+ status="failed",
2267
+ message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
2268
+ phase="service",
2269
+ request_id=request_id,
2270
+ details={
2271
+ "stderr": res.get("stderr"),
2272
+ "stdout": res.get("stdout"),
2273
+ },
2274
+ on_behalf_of_device=device_id,
2275
+ )
2276
+ raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
2277
+
2278
+ _emit_progress_event(
2279
+ self,
2280
+ step_index=install_step,
2281
+ total_steps=total_steps,
2282
+ step_name="launch_portacode_service",
2283
+ step_label=install_label,
2284
+ status="completed",
2285
+ message="Portacode service install finished.",
2286
+ phase="service",
2287
+ request_id=request_id,
2288
+ on_behalf_of_device=device_id,
2289
+ )
2290
+
2291
+ logger.info(
2292
+ "create_proxmox_container: portacode service install completed inside ct %s", vmid
2293
+ )
2294
+
2295
+ current_step_index += 2
2296
+
2297
+ _emit_host_event(
2298
+ self,
2299
+ {
2300
+ "event": "proxmox_container_created",
2301
+ "success": True,
2302
+ "message": f"Container {vmid} is ready and Portacode key captured.",
2303
+ "ctid": str(vmid),
2304
+ "public_key": public_key,
2305
+ "container": {
2306
+ "vmid": vmid,
2307
+ "hostname": payload_local["hostname"],
2308
+ "template": payload_local["template"],
2309
+ "storage": payload_local["storage"],
2310
+ "disk_gib": payload_local["disk_gib"],
2311
+ "ram_mib": payload_local["ram_mib"],
2312
+ "cpus": payload_local["cpus"],
2313
+ },
2314
+ "setup_steps": steps,
2315
+ "device_id": device_id,
2316
+ "on_behalf_of_device": device_id,
2317
+ "service_installed": service_installed,
2318
+ "request_id": request_id,
2319
+ },
2320
+ )
2321
+ except Exception as exc:
2322
+ if reservation_id and not created_record:
2323
+ _release_container_reservation(reservation_id)
2324
+ if vmid is not None and proxmox and node:
2325
+ _cleanup_failed_container(proxmox, node, vmid, provisioning_id)
2326
+ _remove_container_record(vmid)
2327
+ _emit_host_event(
2328
+ self,
2329
+ {
2330
+ "event": "error",
2331
+ "message": str(exc),
2332
+ "device_id": device_id,
2333
+ "request_id": request_id,
2022
2334
  },
2023
- on_behalf_of_device=device_id,
2024
2335
  )
2025
- raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
2026
-
2027
- _emit_progress_event(
2028
- self,
2029
- step_index=install_step,
2030
- total_steps=total_steps,
2031
- step_name="launch_portacode_service",
2032
- step_label=install_label,
2033
- status="completed",
2034
- message="Portacode service install finished.",
2035
- phase="service",
2036
- request_id=request_id,
2037
- on_behalf_of_device=device_id,
2038
- )
2039
-
2040
- logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
2041
2336
 
2042
- current_step_index += 2
2337
+ threading.Thread(target=_provision_background, daemon=True).start()
2043
2338
 
2044
2339
  return {
2045
- "event": "proxmox_container_created",
2340
+ "event": "proxmox_container_accepted",
2046
2341
  "success": True,
2047
- "message": f"Container {vmid} is ready and Portacode key captured.",
2048
- "ctid": str(vmid),
2049
- "public_key": public_key,
2050
- "container": {
2051
- "vmid": vmid,
2052
- "hostname": payload["hostname"],
2053
- "template": payload["template"],
2054
- "storage": payload["storage"],
2055
- "disk_gib": payload["disk_gib"],
2056
- "ram_mib": payload["ram_mib"],
2057
- "cpus": payload["cpus"],
2058
- },
2059
- "setup_steps": steps,
2342
+ "message": "Provisioning accepted; resources reserved.",
2060
2343
  "device_id": device_id,
2061
- "on_behalf_of_device": device_id,
2062
- "service_installed": service_installed,
2344
+ "request_id": request_id,
2063
2345
  }
2064
2346
 
2065
2347
 
@@ -2134,7 +2416,8 @@ class StartPortacodeServiceHandler(SyncHandler):
2134
2416
  on_behalf_of_device=on_behalf_of_device,
2135
2417
  )
2136
2418
 
2137
- cmd = _su_command(user, "sudo -S portacode service install")
2419
+ cli_path = _resolve_portacode_cli_path(vmid, user)
2420
+ cmd = _su_command(user, f"sudo -S {shlex.quote(cli_path)} service install")
2138
2421
  res = _run_pct(vmid, cmd, input_text=password + "\n")
2139
2422
 
2140
2423
  if res["returncode"] != 0:
portacode/service.py CHANGED
@@ -6,6 +6,7 @@ runs ``portacode connect`` automatically at login / boot.
6
6
  Platforms implemented:
7
7
 
8
8
  • Linux (systemd **user** service) – no root privileges required
9
+ • Linux (OpenRC service) – system-wide (e.g., Alpine)
9
10
  • macOS (launchd LaunchAgent plist) – per-user
10
11
  • Windows (Task Scheduler *ONLOGON* task) – highest privilege, current user
11
12
 
@@ -26,6 +27,7 @@ import os
26
27
  from typing import Protocol
27
28
  import shutil
28
29
  import pwd
30
+ import tempfile
29
31
 
30
32
  __all__ = [
31
33
  "ServiceManager",
@@ -199,6 +201,129 @@ class _SystemdUserService:
199
201
  return (status or "") + "\n--- recent logs ---\n" + (journal or "<no logs>")
200
202
 
201
203
 
204
+ # ---------------------------------------------------------------------------
205
+ # Linux – OpenRC implementation (e.g., Alpine)
206
+ # ---------------------------------------------------------------------------
207
+ class _OpenRCService:
208
+ NAME = "portacode"
209
+
210
+ def __init__(self) -> None:
211
+ self.init_path = Path("/etc/init.d") / self.NAME
212
+ self.wrapper_path = Path("/usr/local/share/portacode/connect_service.sh")
213
+ self.user = os.environ.get("SUDO_USER") or os.environ.get("USER") or os.getlogin()
214
+ try:
215
+ self.home = Path(pwd.getpwnam(self.user).pw_dir)
216
+ except KeyError:
217
+ self.home = Path("/root") if self.user == "root" else Path(f"/home/{self.user}")
218
+ self.python = shutil.which("python3") or sys.executable
219
+ self.log_dir = Path("/var/log/portacode")
220
+ self.log_path = self.log_dir / "connect.log"
221
+
222
+ def _run(self, *args: str) -> subprocess.CompletedProcess[str]:
223
+ prefix = ["sudo"] if os.geteuid() != 0 else []
224
+ cmd = [*prefix, *args]
225
+ return subprocess.run(cmd, text=True, capture_output=True)
226
+
227
+ def _write_init_script(self) -> None:
228
+ self._write_wrapper_script()
229
+ script = textwrap.dedent(f"""
230
+ #!/sbin/openrc-run
231
+ description="Portacode persistent connection"
232
+
233
+ command="{self.wrapper_path}"
234
+ command_user="{self.user}"
235
+ command_background="yes"
236
+ pidfile="/run/portacode.pid"
237
+ directory="{self.home}"
238
+
239
+ depend() {{
240
+ need net
241
+ }}
242
+
243
+ start_pre() {{
244
+ checkpath --directory --mode 0755 /var/log/portacode
245
+ checkpath --directory --mode 0755 /usr/local/share/portacode
246
+ touch "{self.log_path}"
247
+ chown {self.user} "{self.log_path}"
248
+ chown {self.user} /var/log/portacode
249
+ }}
250
+ """).lstrip()
251
+
252
+ tmp_path = Path(tempfile.gettempdir()) / f"portacode-init-{os.getpid()}"
253
+ tmp_path.write_text(script)
254
+ if os.geteuid() != 0:
255
+ self._run("install", "-m", "755", str(tmp_path), str(self.init_path))
256
+ else:
257
+ self.init_path.parent.mkdir(parents=True, exist_ok=True)
258
+ shutil.copyfile(tmp_path, self.init_path)
259
+ self.init_path.chmod(0o755)
260
+ try:
261
+ tmp_path.unlink()
262
+ except Exception:
263
+ pass
264
+
265
+ def _write_wrapper_script(self) -> None:
266
+ script = textwrap.dedent(f"""
267
+ #!/bin/sh
268
+ cd "{self.home}"
269
+ exec "{self.python}" -m portacode connect --non-interactive >> "{self.log_path}" 2>&1
270
+ """).lstrip()
271
+ tmp_path = Path(tempfile.gettempdir()) / f"portacode-wrapper-{os.getpid()}"
272
+ tmp_path.write_text(script)
273
+ if os.geteuid() != 0:
274
+ self._run("install", "-m", "755", str(tmp_path), str(self.wrapper_path))
275
+ else:
276
+ self.wrapper_path.parent.mkdir(parents=True, exist_ok=True)
277
+ shutil.copyfile(tmp_path, self.wrapper_path)
278
+ self.wrapper_path.chmod(0o755)
279
+ try:
280
+ tmp_path.unlink()
281
+ except Exception:
282
+ pass
283
+
284
+ def install(self) -> None:
285
+ self._write_init_script()
286
+ self._run("rc-update", "add", self.NAME, "default")
287
+ self._run("rc-service", self.NAME, "start")
288
+
289
+ def uninstall(self) -> None:
290
+ self._run("rc-service", self.NAME, "stop")
291
+ self._run("rc-update", "del", self.NAME, "default")
292
+ if self.init_path.exists():
293
+ if os.geteuid() != 0:
294
+ self._run("rm", "-f", str(self.init_path))
295
+ else:
296
+ self.init_path.unlink()
297
+
298
+ def start(self) -> None:
299
+ self._run("rc-service", self.NAME, "start")
300
+
301
+ def stop(self) -> None:
302
+ self._run("rc-service", self.NAME, "stop")
303
+
304
+ def status(self) -> str:
305
+ res = self._run("rc-service", self.NAME, "status")
306
+ out = (res.stdout or res.stderr or "").strip().lower()
307
+ if "started" in out or "running" in out:
308
+ return "running"
309
+ if "stopped" in out:
310
+ return "stopped"
311
+ return out or "unknown"
312
+
313
+ def status_verbose(self) -> str:
314
+ res = self._run("rc-service", self.NAME, "status")
315
+ status = res.stdout or res.stderr or ""
316
+ log_tail = "<no logs>"
317
+ try:
318
+ if self.log_path.exists():
319
+ with self.log_path.open("r", encoding="utf-8", errors="ignore") as fh:
320
+ lines = fh.readlines()
321
+ log_tail = "".join(lines[-20:]) or "<no logs>"
322
+ except Exception:
323
+ pass
324
+ return (status or "").rstrip() + "\n--- recent logs ---\n" + log_tail
325
+
326
+
202
327
  # ---------------------------------------------------------------------------
203
328
  # macOS – launchd (LaunchAgent) implementation
204
329
  # ---------------------------------------------------------------------------
@@ -422,9 +547,13 @@ class _WindowsTask:
422
547
  def get_manager(system_mode: bool = False) -> ServiceManager:
423
548
  system = platform.system().lower()
424
549
  if system == "linux":
425
- return _SystemdUserService(system_mode=system_mode) # type: ignore[return-value]
550
+ if shutil.which("systemctl"):
551
+ return _SystemdUserService(system_mode=system_mode) # type: ignore[return-value]
552
+ if shutil.which("rc-service") or Path("/sbin/openrc").exists():
553
+ return _OpenRCService() # type: ignore[return-value]
554
+ raise RuntimeError("Unsupported Linux init system (no systemctl or rc-service found)")
426
555
  if system == "darwin":
427
556
  return _LaunchdService() # type: ignore[return-value]
428
557
  if system.startswith("windows") or system == "windows":
429
558
  return _WindowsTask() # type: ignore[return-value]
430
- raise RuntimeError(f"Unsupported platform: {system}")
559
+ raise RuntimeError(f"Unsupported platform: {system}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.16.dev11
3
+ Version: 1.4.17
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,13 +1,13 @@
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=ZsUlJUljVebwmjOdkxbUPcwi8iyVjIuyBU9g68awYpA,721
4
+ portacode/_version.py,sha256=8lzbms9BpD-Oht4SHc7EY9GwLJHe1VkS9qzxWPibYsY,706
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
8
8
  portacode/logging_categories.py,sha256=9m-BYrjyHh1vjZYBQT4JhAh6b_oYUhIWayO-noH1cSE,5063
9
9
  portacode/pairing.py,sha256=OzSuc0GhrknrDrny4aBU6IUnmKzRDTtocuDpyaVnyrs,3116
10
- portacode/service.py,sha256=p-HHMOAl20QsdcJydcZ74Iqes-wl8G8HItdSim30pUk,16537
10
+ portacode/service.py,sha256=8dX9bmJ97r8UuM4PJMICQ_D8kWpMPRIdTgPJfsOKmrM,21617
11
11
  portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJg,883
12
12
  portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
13
13
  portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
@@ -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=-zAmaOTCTJaic8sclb5Z0DVCl_pU7XePo86NKohi3gc,85295
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=6Av5QeNQGh38U0kRzoB4ZICmSrRaWLT3YXHoAqUisv4,99102
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.16.dev11.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.17.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.16.dev11.dist-info/METADATA,sha256=nEvtzLLbq9J8jG-kubMsNMRQ07YdtEczInIarfWpGhY,13052
95
- portacode-1.4.16.dev11.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
- portacode-1.4.16.dev11.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
- portacode-1.4.16.dev11.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
- portacode-1.4.16.dev11.dist-info/RECORD,,
94
+ portacode-1.4.17.dist-info/METADATA,sha256=aGOO0qvH294SN-7MqFeLFJ9x3Em1cnRu-CKPq_zVAUY,13046
95
+ portacode-1.4.17.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
+ portacode-1.4.17.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.17.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.17.dist-info/RECORD,,