portacode 1.4.17__py3-none-any.whl → 1.4.17.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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'
32
- __version_tuple__ = version_tuple = (1, 4, 17)
31
+ __version__ = version = '1.4.17.dev1'
32
+ __version_tuple__ = version_tuple = (1, 4, 17, 'dev1')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -45,38 +45,15 @@ 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_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
- }
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()
56
52
  TEMPLATES_REFRESH_INTERVAL_S = 300
57
53
 
58
54
  ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
59
55
 
60
56
 
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
-
80
57
  def _emit_progress_event(
81
58
  handler: SyncHandler,
82
59
  *,
@@ -598,76 +575,10 @@ def _ensure_containers_dir() -> None:
598
575
  CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
599
576
 
600
577
 
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)
578
+ def _invalidate_managed_containers_cache() -> None:
579
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
580
+ _MANAGED_CONTAINERS_CACHE["timestamp"] = 0.0
581
+ _MANAGED_CONTAINERS_CACHE["summary"] = None
671
582
 
672
583
 
673
584
  def _load_managed_container_records() -> List[Dict[str, Any]]:
@@ -867,131 +778,97 @@ def _build_full_container_summary(records: List[Dict[str, Any]], config: Dict[st
867
778
  return summary
868
779
 
869
780
 
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
781
+ def _compute_free_resources(summary: Dict[str, Any]) -> Dict[str, float]:
782
+ """Return free resources using the same math as the dashboard."""
783
+ free_ram = None
784
+ free_disk = None
785
+ free_cpu = None
786
+ host_ram = summary.get("host_total_ram_mib")
787
+ alloc_ram = summary.get("allocated_ram_mib")
788
+ if host_ram is not None and alloc_ram is not None:
789
+ free_ram = max(float(host_ram) - float(alloc_ram), 0.0)
790
+
791
+ host_disk = summary.get("host_total_disk_gib")
792
+ alloc_disk = summary.get("allocated_disk_gib")
793
+ if host_disk is not None and alloc_disk is not None:
794
+ free_disk = max(float(host_disk) - float(alloc_disk), 0.0)
795
+
796
+ host_cpu = summary.get("host_total_cpu_cores")
797
+ alloc_cpu = summary.get("allocated_cpu_share")
798
+ if host_cpu is not None and alloc_cpu is not None:
799
+ free_cpu = max(float(host_cpu) - float(alloc_cpu), 0.0)
800
+
801
+ return {
802
+ "ram_mib": free_ram,
803
+ "disk_gib": free_disk,
804
+ "cpu_share": free_cpu,
893
805
  }
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
806
 
940
- return summary
941
807
 
808
+ def _assert_capacity_for_payload(payload: Dict[str, Any], summary: Dict[str, Any]) -> None:
809
+ """Validate requested container resources against current free capacity."""
810
+ free = _compute_free_resources(summary)
811
+ shortages: List[str] = []
942
812
 
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
813
+ req_ram = float(payload.get("ram_mib", 0))
814
+ free_ram = free.get("ram_mib")
815
+ if free_ram is not None and req_ram > free_ram:
816
+ shortages.append(f"RAM (need {int(req_ram)} MiB, free {int(free_ram)} MiB)")
988
817
 
818
+ req_disk = float(payload.get("disk_gib", 0))
819
+ free_disk = free.get("disk_gib")
820
+ if free_disk is not None and req_disk > free_disk:
821
+ shortages.append(f"Disk (need {req_disk:.1f} GiB, free {free_disk:.1f} GiB)")
989
822
 
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)
823
+ req_cpu = float(payload.get("cpus", 0))
824
+ free_cpu = free.get("cpu_share")
825
+ if free_cpu is not None and req_cpu > free_cpu:
826
+ shortages.append(f"CPU (need {req_cpu:.2f} vCPU, free {free_cpu:.2f} vCPU)")
827
+
828
+ if shortages:
829
+ raise RuntimeError(f"Insufficient resources: {', '.join(shortages)}")
830
+
831
+
832
+ def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
833
+ def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
834
+ if not records or not config:
835
+ return
836
+ try:
837
+ proxmox = _connect_proxmox(config)
838
+ node = _get_node_from_config(config)
839
+ statuses = {
840
+ str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
841
+ for ct in proxmox.nodes(node).lxc.get()
842
+ }
843
+ except Exception as exc: # pragma: no cover - best effort
844
+ logger.debug("Failed to refresh container statuses: %s", exc)
845
+ return
846
+ for record in records:
847
+ vmid = record.get("vmid")
848
+ if vmid is None:
849
+ continue
850
+ try:
851
+ vmid_key = str(int(vmid))
852
+ except (ValueError, TypeError):
853
+ continue
854
+ status = statuses.get(vmid_key)
855
+ if status:
856
+ record["status"] = status
857
+
858
+ now = time.monotonic()
859
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
860
+ cache_ts = _MANAGED_CONTAINERS_CACHE["timestamp"]
861
+ cached = _MANAGED_CONTAINERS_CACHE["summary"]
862
+ if not force and cached and now - cache_ts < _MANAGED_CONTAINERS_CACHE_TTL_S:
863
+ return cached
864
+ config = _load_config()
865
+ records = _load_managed_container_records()
866
+ _refresh_container_statuses(records, config)
867
+ summary = _build_full_container_summary(records, config)
868
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
869
+ _MANAGED_CONTAINERS_CACHE["timestamp"] = now
870
+ _MANAGED_CONTAINERS_CACHE["summary"] = summary
871
+ return summary
995
872
 
996
873
 
997
874
  def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
@@ -1277,6 +1154,20 @@ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any]
1277
1154
  logger.info("Container %s already running (%ss)", vmid, uptime)
1278
1155
  return status, 0.0
1279
1156
 
1157
+ # Validate capacity using the same math as the dashboard and serialize allocation.
1158
+ with _CAPACITY_LOCK:
1159
+ summary = _get_managed_containers_summary(force=True)
1160
+ cfg = proxmox.nodes(node).lxc(vmid).config.get()
1161
+ payload = {
1162
+ "ram_mib": _to_mib(cfg.get("memory")),
1163
+ "disk_gib": _pick_container_disk_gib("lxc", cfg, {"rootfs": cfg.get("rootfs")}),
1164
+ "cpus": _pick_container_cpu_share("lxc", cfg, {}),
1165
+ }
1166
+ try:
1167
+ _assert_capacity_for_payload(payload, summary)
1168
+ except RuntimeError as exc:
1169
+ raise RuntimeError(f"Not enough resources to start container {vmid}: {exc}") from exc
1170
+
1280
1171
  upid = proxmox.nodes(node).lxc(vmid).status.start.post()
1281
1172
  return _wait_for_task(proxmox, node, upid)
1282
1173
 
@@ -1294,25 +1185,11 @@ def _delete_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any
1294
1185
  return _wait_for_task(proxmox, node, upid)
1295
1186
 
1296
1187
 
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:
1188
+ def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
1312
1189
  _ensure_containers_dir()
1313
1190
  path = CONTAINERS_DIR / f"ct-{vmid}.json"
1314
1191
  path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
1315
- _register_container_record(vmid, payload, reservation_id=reservation_id)
1192
+ _invalidate_managed_containers_cache()
1316
1193
 
1317
1194
 
1318
1195
  def _read_container_record(vmid: int) -> Dict[str, Any]:
@@ -1332,7 +1209,7 @@ def _remove_container_record(vmid: int) -> None:
1332
1209
  path = CONTAINERS_DIR / f"ct-{vmid}.json"
1333
1210
  if path.exists():
1334
1211
  path.unlink()
1335
- _unregister_container_record(vmid)
1212
+ _invalidate_managed_containers_cache()
1336
1213
 
1337
1214
 
1338
1215
  def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
@@ -1438,15 +1315,6 @@ def _su_command(user: str, command: str) -> str:
1438
1315
  return f"su - {user} -s /bin/sh -c {shlex.quote(command)}"
1439
1316
 
1440
1317
 
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
-
1450
1318
  def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
1451
1319
  res = _run_pct(vmid, cmd)
1452
1320
  if res["returncode"] != 0:
@@ -1889,14 +1757,12 @@ def _allocate_vmid(proxmox: Any) -> int:
1889
1757
  return int(proxmox.cluster.nextid.get())
1890
1758
 
1891
1759
 
1892
- def _instantiate_container(
1893
- proxmox: Any, node: str, payload: Dict[str, Any], vmid: Optional[int] = None
1894
- ) -> Tuple[int, float]:
1760
+ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) -> Tuple[int, float]:
1895
1761
  from proxmoxer.core import ResourceException
1896
1762
 
1897
1763
  storage_type = _get_storage_type(proxmox.nodes(node).storage.get(), payload["storage"])
1898
1764
  rootfs = _format_rootfs(payload["storage"], payload["disk_gib"], storage_type)
1899
- vmid = vmid or _allocate_vmid(proxmox)
1765
+ vmid = _allocate_vmid(proxmox)
1900
1766
  if not payload.get("hostname"):
1901
1767
  payload["hostname"] = f"ct{vmid}"
1902
1768
  try:
@@ -1928,43 +1794,6 @@ def _instantiate_container(
1928
1794
  raise RuntimeError(f"Failed to create container: {exc}") from exc
1929
1795
 
1930
1796
 
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
-
1968
1797
  class CreateProxmoxContainerHandler(SyncHandler):
1969
1798
  """Provision a new managed LXC container via the Proxmox API."""
1970
1799
 
@@ -2067,281 +1896,226 @@ class CreateProxmoxContainerHandler(SyncHandler):
2067
1896
  _validate_environment,
2068
1897
  )
2069
1898
 
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
1899
+ def _create_container():
1900
+ proxmox = _connect_proxmox(config)
1901
+ node = config.get("node") or DEFAULT_NODE_NAME
1902
+ payload = _build_container_payload(message, config)
1903
+ payload["cpulimit"] = float(payload["cpus"])
1904
+ payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
1905
+ payload["memory"] = int(payload["ram_mib"])
1906
+ payload["node"] = node
1907
+ # Validate against current free resources (same math as dashboard charts) with serialization.
1908
+ with _CAPACITY_LOCK:
1909
+ summary = _get_managed_containers_summary(force=True)
1910
+ try:
1911
+ _assert_capacity_for_payload(payload, summary)
1912
+ except RuntimeError as exc:
1913
+ raise RuntimeError(f"Not enough resources to create the container safely: {exc}") from exc
1914
+ vmid, _ = _instantiate_container(proxmox, node, payload)
1915
+ logger.debug(
1916
+ "Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
1917
+ node,
1918
+ payload["template"],
1919
+ payload["ram_mib"],
1920
+ payload["cpus"],
1921
+ payload["storage"],
1922
+ )
1923
+ payload["vmid"] = vmid
1924
+ payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1925
+ payload["status"] = "creating"
1926
+ payload["device_id"] = device_id
1927
+ _write_container_record(vmid, payload)
1928
+ return proxmox, node, vmid, payload
1929
+
1930
+ proxmox, node, vmid, payload = _run_lifecycle_step(
1931
+ "create_container",
1932
+ "Creating container",
1933
+ "Provisioning the LXC container…",
1934
+ "Container created.",
1935
+ _create_container,
1936
+ )
1937
+
1938
+ def _start_container_step():
1939
+ _start_container(proxmox, node, vmid)
2076
1940
 
2077
- reservation_id = _reserve_container_resources(
2078
- payload, device_id=device_id, request_id=request_id
1941
+ _run_lifecycle_step(
1942
+ "start_container",
1943
+ "Starting container",
1944
+ "Booting the container…",
1945
+ "Container startup completed.",
1946
+ _start_container_step,
2079
1947
  )
2080
- provisioning_id = secrets.token_hex(6)
2081
- payload["description"] = f"{payload.get('description', MANAGED_MARKER)};provisioning_id={provisioning_id}"
1948
+ _update_container_record(vmid, {"status": "running"})
2082
1949
 
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
- )
1950
+ def _bootstrap_progress_callback(
1951
+ step_index: int,
1952
+ total: int,
1953
+ step: Dict[str, Any],
1954
+ status: str,
1955
+ result: Optional[Dict[str, Any]],
1956
+ ):
1957
+ label = step.get("display_name") or _friendly_step_label(step.get("name", "bootstrap"))
1958
+ error_summary = (result or {}).get("error_summary") or (result or {}).get("error")
1959
+ attempt = (result or {}).get("attempt")
1960
+ if status == "in_progress":
1961
+ message_text = f"{label} is running…"
1962
+ elif status == "completed":
1963
+ message_text = f"{label} completed."
1964
+ elif status == "retrying":
1965
+ attempt_desc = f" (attempt {attempt})" if attempt else ""
1966
+ message_text = f"{label} failed{attempt_desc}; retrying…"
1967
+ else:
1968
+ message_text = f"{label} failed"
1969
+ if error_summary:
1970
+ message_text += f": {error_summary}"
1971
+ details: Dict[str, Any] = {}
1972
+ if attempt:
1973
+ details["attempt"] = attempt
1974
+ if error_summary:
1975
+ details["error_summary"] = error_summary
1976
+ _emit_progress_event(
1977
+ self,
1978
+ step_index=step_index,
1979
+ total_steps=total,
1980
+ step_name=step.get("name", "bootstrap"),
1981
+ step_label=label,
1982
+ status=status,
1983
+ message=message_text,
1984
+ phase="bootstrap",
1985
+ request_id=request_id,
1986
+ details=details or None,
1987
+ on_behalf_of_device=device_id,
1988
+ )
2128
1989
 
2129
- def _start_container_step():
2130
- _start_container(proxmox, node, vmid)
1990
+ public_key, steps = _bootstrap_portacode(
1991
+ vmid,
1992
+ payload["username"],
1993
+ payload["password"],
1994
+ payload["ssh_public_key"],
1995
+ steps=bootstrap_steps,
1996
+ progress_callback=_bootstrap_progress_callback,
1997
+ start_index=current_step_index,
1998
+ total_steps=total_steps,
1999
+ default_public_key=device_public_key if has_device_keypair else None,
2000
+ )
2001
+ current_step_index += len(bootstrap_steps)
2002
+
2003
+ service_installed = False
2004
+ if has_device_keypair:
2005
+ logger.info(
2006
+ "deploying dashboard-provided Portacode keypair (device_id=%s) into container %s",
2007
+ device_id,
2008
+ vmid,
2009
+ )
2010
+ _deploy_device_keypair(
2011
+ vmid,
2012
+ payload["username"],
2013
+ device_private_key,
2014
+ device_public_key,
2015
+ )
2016
+ service_installed = True
2017
+ service_start_index = current_step_index
2131
2018
 
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,
2189
- total_steps=total_steps,
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(
2019
+ auth_step_name = "setup_device_authentication"
2020
+ auth_label = "Setting up device authentication"
2021
+ _emit_progress_event(
2022
+ self,
2023
+ step_index=service_start_index,
2024
+ total_steps=total_steps,
2025
+ step_name=auth_step_name,
2026
+ step_label=auth_label,
2027
+ status="in_progress",
2028
+ message="Notifying the server of the new device…",
2029
+ phase="service",
2030
+ request_id=request_id,
2031
+ on_behalf_of_device=device_id,
2032
+ )
2033
+ _emit_progress_event(
2034
+ self,
2035
+ step_index=service_start_index,
2036
+ total_steps=total_steps,
2037
+ step_name=auth_step_name,
2038
+ step_label=auth_label,
2039
+ status="completed",
2040
+ message="Authentication metadata recorded.",
2041
+ phase="service",
2042
+ request_id=request_id,
2043
+ on_behalf_of_device=device_id,
2044
+ )
2045
+
2046
+ install_step = service_start_index + 1
2047
+ install_label = "Launching Portacode service"
2048
+ _emit_progress_event(
2049
+ self,
2050
+ step_index=install_step,
2051
+ total_steps=total_steps,
2052
+ step_name="launch_portacode_service",
2053
+ step_label=install_label,
2054
+ status="in_progress",
2055
+ message="Running sudo portacode service install…",
2056
+ phase="service",
2057
+ request_id=request_id,
2058
+ on_behalf_of_device=device_id,
2059
+ )
2060
+
2061
+ cmd = _su_command(payload["username"], "sudo -S portacode service install")
2062
+ res = _run_pct(vmid, cmd, input_text=payload["password"] + "\n")
2063
+
2064
+ if res["returncode"] != 0:
2065
+ _emit_progress_event(
2328
2066
  self,
2329
- {
2330
- "event": "error",
2331
- "message": str(exc),
2332
- "device_id": device_id,
2333
- "request_id": request_id,
2067
+ step_index=install_step,
2068
+ total_steps=total_steps,
2069
+ step_name="launch_portacode_service",
2070
+ step_label=install_label,
2071
+ status="failed",
2072
+ message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
2073
+ phase="service",
2074
+ request_id=request_id,
2075
+ details={
2076
+ "stderr": res.get("stderr"),
2077
+ "stdout": res.get("stdout"),
2334
2078
  },
2079
+ on_behalf_of_device=device_id,
2335
2080
  )
2081
+ raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
2336
2082
 
2337
- threading.Thread(target=_provision_background, daemon=True).start()
2083
+ _emit_progress_event(
2084
+ self,
2085
+ step_index=install_step,
2086
+ total_steps=total_steps,
2087
+ step_name="launch_portacode_service",
2088
+ step_label=install_label,
2089
+ status="completed",
2090
+ message="Portacode service install finished.",
2091
+ phase="service",
2092
+ request_id=request_id,
2093
+ on_behalf_of_device=device_id,
2094
+ )
2095
+
2096
+ logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
2097
+
2098
+ current_step_index += 2
2338
2099
 
2339
2100
  return {
2340
- "event": "proxmox_container_accepted",
2101
+ "event": "proxmox_container_created",
2341
2102
  "success": True,
2342
- "message": "Provisioning accepted; resources reserved.",
2103
+ "message": f"Container {vmid} is ready and Portacode key captured.",
2104
+ "ctid": str(vmid),
2105
+ "public_key": public_key,
2106
+ "container": {
2107
+ "vmid": vmid,
2108
+ "hostname": payload["hostname"],
2109
+ "template": payload["template"],
2110
+ "storage": payload["storage"],
2111
+ "disk_gib": payload["disk_gib"],
2112
+ "ram_mib": payload["ram_mib"],
2113
+ "cpus": payload["cpus"],
2114
+ },
2115
+ "setup_steps": steps,
2343
2116
  "device_id": device_id,
2344
- "request_id": request_id,
2117
+ "on_behalf_of_device": device_id,
2118
+ "service_installed": service_installed,
2345
2119
  }
2346
2120
 
2347
2121
 
@@ -2416,8 +2190,7 @@ class StartPortacodeServiceHandler(SyncHandler):
2416
2190
  on_behalf_of_device=on_behalf_of_device,
2417
2191
  )
2418
2192
 
2419
- cli_path = _resolve_portacode_cli_path(vmid, user)
2420
- cmd = _su_command(user, f"sudo -S {shlex.quote(cli_path)} service install")
2193
+ cmd = _su_command(user, "sudo -S portacode service install")
2421
2194
  res = _run_pct(vmid, cmd, input_text=password + "\n")
2422
2195
 
2423
2196
  if res["returncode"] != 0:
portacode/service.py CHANGED
@@ -6,7 +6,6 @@ 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)
10
9
  • macOS (launchd LaunchAgent plist) – per-user
11
10
  • Windows (Task Scheduler *ONLOGON* task) – highest privilege, current user
12
11
 
@@ -27,7 +26,6 @@ import os
27
26
  from typing import Protocol
28
27
  import shutil
29
28
  import pwd
30
- import tempfile
31
29
 
32
30
  __all__ = [
33
31
  "ServiceManager",
@@ -201,129 +199,6 @@ class _SystemdUserService:
201
199
  return (status or "") + "\n--- recent logs ---\n" + (journal or "<no logs>")
202
200
 
203
201
 
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
-
327
202
  # ---------------------------------------------------------------------------
328
203
  # macOS – launchd (LaunchAgent) implementation
329
204
  # ---------------------------------------------------------------------------
@@ -547,13 +422,9 @@ class _WindowsTask:
547
422
  def get_manager(system_mode: bool = False) -> ServiceManager:
548
423
  system = platform.system().lower()
549
424
  if system == "linux":
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)")
425
+ return _SystemdUserService(system_mode=system_mode) # type: ignore[return-value]
555
426
  if system == "darwin":
556
427
  return _LaunchdService() # type: ignore[return-value]
557
428
  if system.startswith("windows") or system == "windows":
558
429
  return _WindowsTask() # type: ignore[return-value]
559
- raise RuntimeError(f"Unsupported platform: {system}")
430
+ raise RuntimeError(f"Unsupported platform: {system}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.17
3
+ Version: 1.4.17.dev1
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=8lzbms9BpD-Oht4SHc7EY9GwLJHe1VkS9qzxWPibYsY,706
4
+ portacode/_version.py,sha256=30JbXF45c9c1UEsvLScl1Hcz0g1qPk9RGMCBErXUK0c,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
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=8dX9bmJ97r8UuM4PJMICQ_D8kWpMPRIdTgPJfsOKmrM,21617
10
+ portacode/service.py,sha256=p-HHMOAl20QsdcJydcZ74Iqes-wl8G8HItdSim30pUk,16537
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=6Av5QeNQGh38U0kRzoB4ZICmSrRaWLT3YXHoAqUisv4,99102
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=afndfVsjcHWHsa8F2PkRnHmc67c39k5VMmJeUoSSnok,87601
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.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.17.dev1.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.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,,
94
+ portacode-1.4.17.dev1.dist-info/METADATA,sha256=fiXLmOIToLe48UzncEAQCZnpib1ITZpl0gS06M5fmFA,13051
95
+ portacode-1.4.17.dev1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
+ portacode-1.4.17.dev1.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.17.dev1.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.17.dev1.dist-info/RECORD,,