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 +2 -2
- portacode/connection/handlers/proxmox_infra.py +320 -547
- portacode/service.py +2 -131
- {portacode-1.4.17.dist-info → portacode-1.4.17.dev1.dist-info}/METADATA +1 -1
- {portacode-1.4.17.dist-info → portacode-1.4.17.dev1.dist-info}/RECORD +9 -9
- {portacode-1.4.17.dist-info → portacode-1.4.17.dev1.dist-info}/WHEEL +0 -0
- {portacode-1.4.17.dist-info → portacode-1.4.17.dev1.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.17.dist-info → portacode-1.4.17.dev1.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.17.dist-info → portacode-1.4.17.dev1.dist-info}/top_level.txt +0 -0
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
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
|
-
|
|
2078
|
-
|
|
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
|
-
|
|
2081
|
-
payload["description"] = f"{payload.get('description', MANAGED_MARKER)};provisioning_id={provisioning_id}"
|
|
1948
|
+
_update_container_record(vmid, {"status": "running"})
|
|
2082
1949
|
|
|
2083
|
-
def
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
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
|
-
|
|
2130
|
-
|
|
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
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
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
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
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
|
-
|
|
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": "
|
|
2101
|
+
"event": "proxmox_container_created",
|
|
2341
2102
|
"success": True,
|
|
2342
|
-
"message": "
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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,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=
|
|
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=
|
|
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=
|
|
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=
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|