portacode 1.4.16.dev11__py3-none-any.whl → 1.4.17__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- portacode/_version.py +2 -2
- portacode/connection/handlers/proxmox_infra.py +552 -269
- portacode/service.py +131 -2
- {portacode-1.4.16.dev11.dist-info → portacode-1.4.17.dist-info}/METADATA +1 -1
- {portacode-1.4.16.dev11.dist-info → portacode-1.4.17.dist-info}/RECORD +9 -9
- {portacode-1.4.16.dev11.dist-info → portacode-1.4.17.dist-info}/WHEEL +0 -0
- {portacode-1.4.16.dev11.dist-info → portacode-1.4.17.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.16.dev11.dist-info → portacode-1.4.17.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.16.dev11.dist-info → portacode-1.4.17.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.
|
|
32
|
-
__version_tuple__ = version_tuple = (1, 4,
|
|
31
|
+
__version__ = version = '1.4.17'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 4, 17)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -45,14 +45,38 @@ DNS_SERVER = "1.1.1.1"
|
|
|
45
45
|
IFACES_PATH = Path("/etc/network/interfaces")
|
|
46
46
|
SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
|
|
47
47
|
UNIT_DIR = Path("/etc/systemd/system")
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
_MANAGED_CONTAINERS_STATE_LOCK = threading.Lock()
|
|
49
|
+
_MANAGED_CONTAINERS_STATE: Dict[str, Any] = {
|
|
50
|
+
"initialized": False,
|
|
51
|
+
"base_summary": None,
|
|
52
|
+
"initial_totals": {"ram_mib": 0, "disk_gib": 0, "cpu_share": 0.0},
|
|
53
|
+
"records": {},
|
|
54
|
+
"pending": {},
|
|
55
|
+
}
|
|
51
56
|
TEMPLATES_REFRESH_INTERVAL_S = 300
|
|
52
57
|
|
|
53
58
|
ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
|
|
54
59
|
|
|
55
60
|
|
|
61
|
+
def _emit_host_event(
|
|
62
|
+
handler: SyncHandler,
|
|
63
|
+
payload: Dict[str, Any],
|
|
64
|
+
) -> None:
|
|
65
|
+
loop = handler.context.get("event_loop")
|
|
66
|
+
if not loop or loop.is_closed():
|
|
67
|
+
logger.debug("host event skipped (no event loop) event=%s", payload.get("event"))
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
|
|
71
|
+
future.add_done_callback(
|
|
72
|
+
lambda fut: logger.warning(
|
|
73
|
+
"Failed to emit host event %s: %s", payload.get("event"), fut.exception()
|
|
74
|
+
)
|
|
75
|
+
if fut.exception()
|
|
76
|
+
else None
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
56
80
|
def _emit_progress_event(
|
|
57
81
|
handler: SyncHandler,
|
|
58
82
|
*,
|
|
@@ -574,10 +598,76 @@ def _ensure_containers_dir() -> None:
|
|
|
574
598
|
CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
|
|
575
599
|
|
|
576
600
|
|
|
577
|
-
def
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
601
|
+
def _copy_summary(summary: Dict[str, Any]) -> Dict[str, Any]:
|
|
602
|
+
snapshot = summary.copy()
|
|
603
|
+
containers = summary.get("containers")
|
|
604
|
+
if isinstance(containers, list):
|
|
605
|
+
snapshot["containers"] = [entry.copy() for entry in containers]
|
|
606
|
+
unmanaged = summary.get("unmanaged_containers")
|
|
607
|
+
if isinstance(unmanaged, list):
|
|
608
|
+
snapshot["unmanaged_containers"] = [entry.copy() for entry in unmanaged]
|
|
609
|
+
return snapshot
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
|
|
613
|
+
if not records or not config:
|
|
614
|
+
return
|
|
615
|
+
try:
|
|
616
|
+
proxmox = _connect_proxmox(config)
|
|
617
|
+
node = _get_node_from_config(config)
|
|
618
|
+
statuses = {
|
|
619
|
+
str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
|
|
620
|
+
for ct in proxmox.nodes(node).lxc.get()
|
|
621
|
+
}
|
|
622
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
623
|
+
logger.debug("Failed to refresh container statuses: %s", exc)
|
|
624
|
+
return
|
|
625
|
+
for record in records:
|
|
626
|
+
vmid = record.get("vmid")
|
|
627
|
+
if vmid is None:
|
|
628
|
+
continue
|
|
629
|
+
try:
|
|
630
|
+
vmid_key = str(int(vmid))
|
|
631
|
+
except (ValueError, TypeError):
|
|
632
|
+
continue
|
|
633
|
+
status = statuses.get(vmid_key)
|
|
634
|
+
if status:
|
|
635
|
+
record["status"] = status
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _initialize_managed_containers_state(force: bool = False) -> Dict[str, Any]:
|
|
639
|
+
with _MANAGED_CONTAINERS_STATE_LOCK:
|
|
640
|
+
if _MANAGED_CONTAINERS_STATE["initialized"] and not force:
|
|
641
|
+
base = _MANAGED_CONTAINERS_STATE["base_summary"]
|
|
642
|
+
return _copy_summary(base) if base else {}
|
|
643
|
+
|
|
644
|
+
config = _load_config()
|
|
645
|
+
records = _load_managed_container_records()
|
|
646
|
+
_refresh_container_statuses(records, config)
|
|
647
|
+
base_summary = _build_full_container_summary(records, config)
|
|
648
|
+
record_map: Dict[str, Dict[str, Any]] = {}
|
|
649
|
+
for record in records:
|
|
650
|
+
vmid = record.get("vmid")
|
|
651
|
+
if vmid is None:
|
|
652
|
+
continue
|
|
653
|
+
try:
|
|
654
|
+
record_map[str(int(vmid))] = record
|
|
655
|
+
except (TypeError, ValueError):
|
|
656
|
+
continue
|
|
657
|
+
|
|
658
|
+
initial_totals = {
|
|
659
|
+
"ram_mib": int(base_summary.get("total_ram_mib") or 0),
|
|
660
|
+
"disk_gib": int(base_summary.get("total_disk_gib") or 0),
|
|
661
|
+
"cpu_share": float(base_summary.get("total_cpu_share") or 0.0),
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
with _MANAGED_CONTAINERS_STATE_LOCK:
|
|
665
|
+
_MANAGED_CONTAINERS_STATE["initialized"] = True
|
|
666
|
+
_MANAGED_CONTAINERS_STATE["base_summary"] = base_summary
|
|
667
|
+
_MANAGED_CONTAINERS_STATE["initial_totals"] = initial_totals
|
|
668
|
+
_MANAGED_CONTAINERS_STATE["records"] = record_map
|
|
669
|
+
_MANAGED_CONTAINERS_STATE["pending"] = {}
|
|
670
|
+
return _copy_summary(base_summary)
|
|
581
671
|
|
|
582
672
|
|
|
583
673
|
def _load_managed_container_records() -> List[Dict[str, Any]]:
|
|
@@ -777,48 +867,133 @@ def _build_full_container_summary(records: List[Dict[str, Any]], config: Dict[st
|
|
|
777
867
|
return summary
|
|
778
868
|
|
|
779
869
|
|
|
780
|
-
def
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
870
|
+
def _pending_totals(pending: Iterable[Dict[str, Any]]) -> Tuple[int, int, float]:
|
|
871
|
+
ram_total = 0
|
|
872
|
+
disk_total = 0
|
|
873
|
+
cpu_total = 0.0
|
|
874
|
+
for entry in pending:
|
|
875
|
+
ram_total += int(entry.get("ram_mib") or 0)
|
|
876
|
+
disk_total += int(entry.get("disk_gib") or 0)
|
|
877
|
+
cpu_total += float(entry.get("cpu_share") or 0.0)
|
|
878
|
+
return ram_total, disk_total, cpu_total
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _compose_managed_containers_summary(
|
|
882
|
+
records: Iterable[Dict[str, Any]],
|
|
883
|
+
pending: Iterable[Dict[str, Any]],
|
|
884
|
+
base_summary: Dict[str, Any],
|
|
885
|
+
initial_totals: Dict[str, Any],
|
|
886
|
+
) -> Dict[str, Any]:
|
|
887
|
+
managed_summary = _build_managed_containers_summary(list(records))
|
|
888
|
+
summary = _copy_summary(base_summary)
|
|
889
|
+
base_by_vmid = {
|
|
890
|
+
str(entry.get("vmid")): entry
|
|
891
|
+
for entry in base_summary.get("containers", [])
|
|
892
|
+
if entry.get("vmid") is not None
|
|
893
|
+
}
|
|
894
|
+
default_storage = summary.get("default_storage")
|
|
895
|
+
merged_containers: List[Dict[str, Any]] = []
|
|
896
|
+
for entry in managed_summary["containers"]:
|
|
897
|
+
vmid = str(entry.get("vmid")) if entry.get("vmid") is not None else None
|
|
898
|
+
base_entry = base_by_vmid.get(vmid) if vmid is not None else None
|
|
899
|
+
if base_entry:
|
|
900
|
+
merged = base_entry.copy()
|
|
901
|
+
merged.update(entry)
|
|
902
|
+
else:
|
|
903
|
+
merged = entry.copy()
|
|
904
|
+
merged.setdefault("managed", True)
|
|
905
|
+
merged.setdefault("type", "lxc")
|
|
906
|
+
if default_storage and merged.get("storage"):
|
|
907
|
+
merged["matches_default_storage"] = (
|
|
908
|
+
str(merged["storage"]).lower() == str(default_storage).lower()
|
|
909
|
+
)
|
|
910
|
+
merged_containers.append(merged)
|
|
911
|
+
summary["updated_at"] = managed_summary["updated_at"]
|
|
912
|
+
summary["count"] = managed_summary["count"]
|
|
913
|
+
summary["total_ram_mib"] = managed_summary["total_ram_mib"]
|
|
914
|
+
summary["total_disk_gib"] = managed_summary["total_disk_gib"]
|
|
915
|
+
summary["total_cpu_share"] = managed_summary["total_cpu_share"]
|
|
916
|
+
summary["containers"] = merged_containers
|
|
917
|
+
|
|
918
|
+
pending_ram, pending_disk, pending_cpu = _pending_totals(pending)
|
|
919
|
+
delta_ram = managed_summary["total_ram_mib"] - int(initial_totals.get("ram_mib") or 0)
|
|
920
|
+
delta_disk = managed_summary["total_disk_gib"] - int(initial_totals.get("disk_gib") or 0)
|
|
921
|
+
delta_cpu = managed_summary["total_cpu_share"] - float(initial_totals.get("cpu_share") or 0.0)
|
|
922
|
+
|
|
923
|
+
if "allocated_ram_mib" in summary and summary.get("allocated_ram_mib") is not None:
|
|
924
|
+
summary["allocated_ram_mib"] = round(float(summary["allocated_ram_mib"]) + delta_ram + pending_ram, 2)
|
|
925
|
+
if "allocated_disk_gib" in summary and summary.get("allocated_disk_gib") is not None:
|
|
926
|
+
summary["allocated_disk_gib"] = round(float(summary["allocated_disk_gib"]) + delta_disk + pending_disk, 2)
|
|
927
|
+
if "allocated_cpu_share" in summary and summary.get("allocated_cpu_share") is not None:
|
|
928
|
+
summary["allocated_cpu_share"] = round(float(summary["allocated_cpu_share"]) + delta_cpu + pending_cpu, 2)
|
|
929
|
+
|
|
930
|
+
if "available_ram_mib" in summary and summary.get("available_ram_mib") is not None:
|
|
931
|
+
available_ram = float(summary["available_ram_mib"]) - delta_ram - pending_ram
|
|
932
|
+
summary["available_ram_mib"] = max(int(available_ram), 0)
|
|
933
|
+
if "available_disk_gib" in summary and summary.get("available_disk_gib") is not None:
|
|
934
|
+
available_disk = float(summary["available_disk_gib"]) - delta_disk - pending_disk
|
|
935
|
+
summary["available_disk_gib"] = max(available_disk, 0.0)
|
|
936
|
+
if "available_cpu_share" in summary and summary.get("available_cpu_share") is not None:
|
|
937
|
+
available_cpu = float(summary["available_cpu_share"]) - delta_cpu - pending_cpu
|
|
938
|
+
summary["available_cpu_share"] = max(available_cpu, 0.0)
|
|
939
|
+
|
|
819
940
|
return summary
|
|
820
941
|
|
|
821
942
|
|
|
943
|
+
def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
|
|
944
|
+
_initialize_managed_containers_state(force=force)
|
|
945
|
+
with _MANAGED_CONTAINERS_STATE_LOCK:
|
|
946
|
+
base_summary = _MANAGED_CONTAINERS_STATE.get("base_summary") or {}
|
|
947
|
+
records = list(_MANAGED_CONTAINERS_STATE.get("records", {}).values())
|
|
948
|
+
pending = list(_MANAGED_CONTAINERS_STATE.get("pending", {}).values())
|
|
949
|
+
initial_totals = _MANAGED_CONTAINERS_STATE.get("initial_totals", {})
|
|
950
|
+
if not base_summary:
|
|
951
|
+
return {}
|
|
952
|
+
return _compose_managed_containers_summary(records, pending, base_summary, initial_totals)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _reserve_container_resources(payload: Dict[str, Any], *, device_id: str, request_id: Optional[str]) -> str:
|
|
956
|
+
_initialize_managed_containers_state()
|
|
957
|
+
ram_mib = int(payload.get("ram_mib") or 0)
|
|
958
|
+
disk_gib = int(payload.get("disk_gib") or 0)
|
|
959
|
+
cpu_share = float(payload.get("cpus") or 0.0)
|
|
960
|
+
reservation_id = secrets.token_hex(8)
|
|
961
|
+
with _MANAGED_CONTAINERS_STATE_LOCK:
|
|
962
|
+
base_summary = _MANAGED_CONTAINERS_STATE.get("base_summary") or {}
|
|
963
|
+
records = list(_MANAGED_CONTAINERS_STATE.get("records", {}).values())
|
|
964
|
+
pending = list(_MANAGED_CONTAINERS_STATE.get("pending", {}).values())
|
|
965
|
+
initial_totals = _MANAGED_CONTAINERS_STATE.get("initial_totals", {})
|
|
966
|
+
summary = _compose_managed_containers_summary(records, pending, base_summary, initial_totals)
|
|
967
|
+
|
|
968
|
+
available_ram = summary.get("available_ram_mib")
|
|
969
|
+
if available_ram is not None and ram_mib > available_ram:
|
|
970
|
+
raise RuntimeError("Not enough RAM to create this container.")
|
|
971
|
+
available_disk = summary.get("available_disk_gib")
|
|
972
|
+
if available_disk is not None and disk_gib > available_disk:
|
|
973
|
+
raise RuntimeError("Not enough disk space to create this container.")
|
|
974
|
+
available_cpu = summary.get("available_cpu_share")
|
|
975
|
+
if available_cpu is not None and cpu_share > available_cpu:
|
|
976
|
+
raise RuntimeError("Not enough CPU capacity to create this container.")
|
|
977
|
+
|
|
978
|
+
_MANAGED_CONTAINERS_STATE["pending"][reservation_id] = {
|
|
979
|
+
"reservation_id": reservation_id,
|
|
980
|
+
"device_id": device_id,
|
|
981
|
+
"request_id": request_id,
|
|
982
|
+
"ram_mib": ram_mib,
|
|
983
|
+
"disk_gib": disk_gib,
|
|
984
|
+
"cpu_share": cpu_share,
|
|
985
|
+
"created_at": datetime.utcnow().isoformat() + "Z",
|
|
986
|
+
}
|
|
987
|
+
return reservation_id
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _release_container_reservation(reservation_id: Optional[str]) -> None:
|
|
991
|
+
if not reservation_id:
|
|
992
|
+
return
|
|
993
|
+
with _MANAGED_CONTAINERS_STATE_LOCK:
|
|
994
|
+
_MANAGED_CONTAINERS_STATE.get("pending", {}).pop(reservation_id, None)
|
|
995
|
+
|
|
996
|
+
|
|
822
997
|
def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
|
|
823
998
|
if storage_type in ("lvm", "lvmthin"):
|
|
824
999
|
return f"{storage}:{disk_gib}"
|
|
@@ -1102,23 +1277,6 @@ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any]
|
|
|
1102
1277
|
logger.info("Container %s already running (%ss)", vmid, uptime)
|
|
1103
1278
|
return status, 0.0
|
|
1104
1279
|
|
|
1105
|
-
node_status = proxmox.nodes(node).status.get()
|
|
1106
|
-
mem_total_mb = int(node_status.get("memory", {}).get("total", 0) // (1024**2))
|
|
1107
|
-
cores_total = int(node_status.get("cpuinfo", {}).get("cores", 0))
|
|
1108
|
-
|
|
1109
|
-
running = _list_running_managed(proxmox, node)
|
|
1110
|
-
used_mem_mb = sum(int(cfg.get("memory", 0)) for _, cfg in running)
|
|
1111
|
-
used_cores = sum(int(cfg.get("cores", 0)) for _, cfg in running)
|
|
1112
|
-
|
|
1113
|
-
target_cfg = proxmox.nodes(node).lxc(vmid).config.get()
|
|
1114
|
-
target_mem_mb = int(target_cfg.get("memory", 0))
|
|
1115
|
-
target_cores = int(target_cfg.get("cores", 0))
|
|
1116
|
-
|
|
1117
|
-
if mem_total_mb and used_mem_mb + target_mem_mb > mem_total_mb:
|
|
1118
|
-
raise RuntimeError("Not enough RAM to start this container safely.")
|
|
1119
|
-
if cores_total and used_cores + target_cores > cores_total:
|
|
1120
|
-
raise RuntimeError("Not enough CPU cores to start this container safely.")
|
|
1121
|
-
|
|
1122
1280
|
upid = proxmox.nodes(node).lxc(vmid).status.start.post()
|
|
1123
1281
|
return _wait_for_task(proxmox, node, upid)
|
|
1124
1282
|
|
|
@@ -1136,11 +1294,25 @@ def _delete_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any
|
|
|
1136
1294
|
return _wait_for_task(proxmox, node, upid)
|
|
1137
1295
|
|
|
1138
1296
|
|
|
1139
|
-
def
|
|
1297
|
+
def _register_container_record(vmid: int, payload: Dict[str, Any], reservation_id: Optional[str] = None) -> None:
|
|
1298
|
+
_initialize_managed_containers_state()
|
|
1299
|
+
with _MANAGED_CONTAINERS_STATE_LOCK:
|
|
1300
|
+
if reservation_id:
|
|
1301
|
+
_MANAGED_CONTAINERS_STATE["pending"].pop(reservation_id, None)
|
|
1302
|
+
_MANAGED_CONTAINERS_STATE["records"][str(vmid)] = payload.copy()
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _unregister_container_record(vmid: int) -> None:
|
|
1306
|
+
_initialize_managed_containers_state()
|
|
1307
|
+
with _MANAGED_CONTAINERS_STATE_LOCK:
|
|
1308
|
+
_MANAGED_CONTAINERS_STATE["records"].pop(str(vmid), None)
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _write_container_record(vmid: int, payload: Dict[str, Any], reservation_id: Optional[str] = None) -> None:
|
|
1140
1312
|
_ensure_containers_dir()
|
|
1141
1313
|
path = CONTAINERS_DIR / f"ct-{vmid}.json"
|
|
1142
1314
|
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
1143
|
-
|
|
1315
|
+
_register_container_record(vmid, payload, reservation_id=reservation_id)
|
|
1144
1316
|
|
|
1145
1317
|
|
|
1146
1318
|
def _read_container_record(vmid: int) -> Dict[str, Any]:
|
|
@@ -1160,7 +1332,7 @@ def _remove_container_record(vmid: int) -> None:
|
|
|
1160
1332
|
path = CONTAINERS_DIR / f"ct-{vmid}.json"
|
|
1161
1333
|
if path.exists():
|
|
1162
1334
|
path.unlink()
|
|
1163
|
-
|
|
1335
|
+
_unregister_container_record(vmid)
|
|
1164
1336
|
|
|
1165
1337
|
|
|
1166
1338
|
def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -1266,6 +1438,15 @@ def _su_command(user: str, command: str) -> str:
|
|
|
1266
1438
|
return f"su - {user} -s /bin/sh -c {shlex.quote(command)}"
|
|
1267
1439
|
|
|
1268
1440
|
|
|
1441
|
+
def _resolve_portacode_cli_path(vmid: int, user: str) -> str:
|
|
1442
|
+
"""Resolve the full path to the portacode CLI inside the container."""
|
|
1443
|
+
res = _run_pct(vmid, _su_command(user, "command -v portacode"))
|
|
1444
|
+
path = (res.get("stdout") or "").strip()
|
|
1445
|
+
if path:
|
|
1446
|
+
return path
|
|
1447
|
+
return "portacode"
|
|
1448
|
+
|
|
1449
|
+
|
|
1269
1450
|
def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
|
|
1270
1451
|
res = _run_pct(vmid, cmd)
|
|
1271
1452
|
if res["returncode"] != 0:
|
|
@@ -1708,12 +1889,14 @@ def _allocate_vmid(proxmox: Any) -> int:
|
|
|
1708
1889
|
return int(proxmox.cluster.nextid.get())
|
|
1709
1890
|
|
|
1710
1891
|
|
|
1711
|
-
def _instantiate_container(
|
|
1892
|
+
def _instantiate_container(
|
|
1893
|
+
proxmox: Any, node: str, payload: Dict[str, Any], vmid: Optional[int] = None
|
|
1894
|
+
) -> Tuple[int, float]:
|
|
1712
1895
|
from proxmoxer.core import ResourceException
|
|
1713
1896
|
|
|
1714
1897
|
storage_type = _get_storage_type(proxmox.nodes(node).storage.get(), payload["storage"])
|
|
1715
1898
|
rootfs = _format_rootfs(payload["storage"], payload["disk_gib"], storage_type)
|
|
1716
|
-
vmid = _allocate_vmid(proxmox)
|
|
1899
|
+
vmid = vmid or _allocate_vmid(proxmox)
|
|
1717
1900
|
if not payload.get("hostname"):
|
|
1718
1901
|
payload["hostname"] = f"ct{vmid}"
|
|
1719
1902
|
try:
|
|
@@ -1745,6 +1928,43 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
|
|
|
1745
1928
|
raise RuntimeError(f"Failed to create container: {exc}") from exc
|
|
1746
1929
|
|
|
1747
1930
|
|
|
1931
|
+
def _cleanup_failed_container(
|
|
1932
|
+
proxmox: Any, node: str, vmid: int, provisioning_id: Optional[str]
|
|
1933
|
+
) -> None:
|
|
1934
|
+
from proxmoxer.core import ResourceException
|
|
1935
|
+
|
|
1936
|
+
try:
|
|
1937
|
+
cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
|
|
1938
|
+
except ResourceException as exc:
|
|
1939
|
+
msg = str(exc).lower()
|
|
1940
|
+
if "does not exist" in msg or "not found" in msg:
|
|
1941
|
+
return
|
|
1942
|
+
logger.warning("Failed to inspect container %s after create failure: %s", vmid, exc)
|
|
1943
|
+
return
|
|
1944
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
1945
|
+
logger.warning("Failed to inspect container %s after create failure: %s", vmid, exc)
|
|
1946
|
+
return
|
|
1947
|
+
|
|
1948
|
+
description = (cfg or {}).get("description") or ""
|
|
1949
|
+
if provisioning_id and provisioning_id not in description:
|
|
1950
|
+
logger.warning(
|
|
1951
|
+
"Skipping cleanup for vmid=%s; provisioning marker mismatch", vmid
|
|
1952
|
+
)
|
|
1953
|
+
return
|
|
1954
|
+
|
|
1955
|
+
try:
|
|
1956
|
+
status = proxmox.nodes(node).lxc(str(vmid)).status.current.get()
|
|
1957
|
+
if status.get("status") == "running":
|
|
1958
|
+
_stop_container(proxmox, node, vmid)
|
|
1959
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
1960
|
+
logger.warning("Failed to stop container %s after create failure: %s", vmid, exc)
|
|
1961
|
+
|
|
1962
|
+
try:
|
|
1963
|
+
_delete_container(proxmox, node, vmid)
|
|
1964
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
1965
|
+
logger.warning("Failed to delete container %s after create failure: %s", vmid, exc)
|
|
1966
|
+
|
|
1967
|
+
|
|
1748
1968
|
class CreateProxmoxContainerHandler(SyncHandler):
|
|
1749
1969
|
"""Provision a new managed LXC container via the Proxmox API."""
|
|
1750
1970
|
|
|
@@ -1847,219 +2067,281 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1847
2067
|
_validate_environment,
|
|
1848
2068
|
)
|
|
1849
2069
|
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
payload["memory"] = int(payload["ram_mib"])
|
|
1857
|
-
payload["node"] = node
|
|
1858
|
-
logger.debug(
|
|
1859
|
-
"Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
|
|
1860
|
-
node,
|
|
1861
|
-
payload["template"],
|
|
1862
|
-
payload["ram_mib"],
|
|
1863
|
-
payload["cpus"],
|
|
1864
|
-
payload["storage"],
|
|
1865
|
-
)
|
|
1866
|
-
vmid, _ = _instantiate_container(proxmox, node, payload)
|
|
1867
|
-
payload["vmid"] = vmid
|
|
1868
|
-
payload["created_at"] = datetime.utcnow().isoformat() + "Z"
|
|
1869
|
-
payload["status"] = "creating"
|
|
1870
|
-
payload["device_id"] = device_id
|
|
1871
|
-
_write_container_record(vmid, payload)
|
|
1872
|
-
return proxmox, node, vmid, payload
|
|
1873
|
-
|
|
1874
|
-
proxmox, node, vmid, payload = _run_lifecycle_step(
|
|
1875
|
-
"create_container",
|
|
1876
|
-
"Creating container",
|
|
1877
|
-
"Provisioning the LXC container…",
|
|
1878
|
-
"Container created.",
|
|
1879
|
-
_create_container,
|
|
1880
|
-
)
|
|
1881
|
-
|
|
1882
|
-
def _start_container_step():
|
|
1883
|
-
_start_container(proxmox, node, vmid)
|
|
2070
|
+
node = config.get("node") or DEFAULT_NODE_NAME
|
|
2071
|
+
payload = _build_container_payload(message, config)
|
|
2072
|
+
payload["cpulimit"] = float(payload["cpus"])
|
|
2073
|
+
payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
|
|
2074
|
+
payload["memory"] = int(payload["ram_mib"])
|
|
2075
|
+
payload["node"] = node
|
|
1884
2076
|
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
"Starting container",
|
|
1888
|
-
"Booting the container…",
|
|
1889
|
-
"Container startup completed.",
|
|
1890
|
-
_start_container_step,
|
|
2077
|
+
reservation_id = _reserve_container_resources(
|
|
2078
|
+
payload, device_id=device_id, request_id=request_id
|
|
1891
2079
|
)
|
|
1892
|
-
|
|
2080
|
+
provisioning_id = secrets.token_hex(6)
|
|
2081
|
+
payload["description"] = f"{payload.get('description', MANAGED_MARKER)};provisioning_id={provisioning_id}"
|
|
1893
2082
|
|
|
1894
|
-
def
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
steps=bootstrap_steps,
|
|
1940
|
-
progress_callback=_bootstrap_progress_callback,
|
|
1941
|
-
start_index=current_step_index,
|
|
1942
|
-
total_steps=total_steps,
|
|
1943
|
-
default_public_key=device_public_key if has_device_keypair else None,
|
|
1944
|
-
)
|
|
1945
|
-
current_step_index += len(bootstrap_steps)
|
|
1946
|
-
|
|
1947
|
-
service_installed = False
|
|
1948
|
-
if has_device_keypair:
|
|
1949
|
-
logger.info(
|
|
1950
|
-
"deploying dashboard-provided Portacode keypair (device_id=%s) into container %s",
|
|
1951
|
-
device_id,
|
|
1952
|
-
vmid,
|
|
1953
|
-
)
|
|
1954
|
-
_deploy_device_keypair(
|
|
1955
|
-
vmid,
|
|
1956
|
-
payload["username"],
|
|
1957
|
-
device_private_key,
|
|
1958
|
-
device_public_key,
|
|
1959
|
-
)
|
|
1960
|
-
service_installed = True
|
|
1961
|
-
service_start_index = current_step_index
|
|
1962
|
-
|
|
1963
|
-
auth_step_name = "setup_device_authentication"
|
|
1964
|
-
auth_label = "Setting up device authentication"
|
|
1965
|
-
_emit_progress_event(
|
|
1966
|
-
self,
|
|
1967
|
-
step_index=service_start_index,
|
|
1968
|
-
total_steps=total_steps,
|
|
1969
|
-
step_name=auth_step_name,
|
|
1970
|
-
step_label=auth_label,
|
|
1971
|
-
status="in_progress",
|
|
1972
|
-
message="Notifying the server of the new device…",
|
|
1973
|
-
phase="service",
|
|
1974
|
-
request_id=request_id,
|
|
1975
|
-
on_behalf_of_device=device_id,
|
|
1976
|
-
)
|
|
1977
|
-
_emit_progress_event(
|
|
1978
|
-
self,
|
|
1979
|
-
step_index=service_start_index,
|
|
1980
|
-
total_steps=total_steps,
|
|
1981
|
-
step_name=auth_step_name,
|
|
1982
|
-
step_label=auth_label,
|
|
1983
|
-
status="completed",
|
|
1984
|
-
message="Authentication metadata recorded.",
|
|
1985
|
-
phase="service",
|
|
1986
|
-
request_id=request_id,
|
|
1987
|
-
on_behalf_of_device=device_id,
|
|
1988
|
-
)
|
|
1989
|
-
|
|
1990
|
-
install_step = service_start_index + 1
|
|
1991
|
-
install_label = "Launching Portacode service"
|
|
1992
|
-
_emit_progress_event(
|
|
1993
|
-
self,
|
|
1994
|
-
step_index=install_step,
|
|
1995
|
-
total_steps=total_steps,
|
|
1996
|
-
step_name="launch_portacode_service",
|
|
1997
|
-
step_label=install_label,
|
|
1998
|
-
status="in_progress",
|
|
1999
|
-
message="Running sudo portacode service install…",
|
|
2000
|
-
phase="service",
|
|
2001
|
-
request_id=request_id,
|
|
2002
|
-
on_behalf_of_device=device_id,
|
|
2003
|
-
)
|
|
2083
|
+
def _provision_background() -> None:
|
|
2084
|
+
nonlocal current_step_index
|
|
2085
|
+
proxmox: Any = None
|
|
2086
|
+
vmid: Optional[int] = None
|
|
2087
|
+
created_record = False
|
|
2088
|
+
try:
|
|
2089
|
+
def _create_container():
|
|
2090
|
+
nonlocal proxmox, vmid, created_record
|
|
2091
|
+
proxmox = _connect_proxmox(config)
|
|
2092
|
+
logger.debug(
|
|
2093
|
+
"Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
|
|
2094
|
+
node,
|
|
2095
|
+
payload["template"],
|
|
2096
|
+
payload["ram_mib"],
|
|
2097
|
+
payload["cpus"],
|
|
2098
|
+
payload["storage"],
|
|
2099
|
+
)
|
|
2100
|
+
try:
|
|
2101
|
+
vmid = _allocate_vmid(proxmox)
|
|
2102
|
+
vmid, _ = _instantiate_container(proxmox, node, payload, vmid=vmid)
|
|
2103
|
+
except Exception:
|
|
2104
|
+
_release_container_reservation(reservation_id)
|
|
2105
|
+
if vmid is not None:
|
|
2106
|
+
_cleanup_failed_container(proxmox, node, vmid, provisioning_id)
|
|
2107
|
+
raise
|
|
2108
|
+
payload["vmid"] = vmid
|
|
2109
|
+
payload["created_at"] = datetime.utcnow().isoformat() + "Z"
|
|
2110
|
+
payload["status"] = "creating"
|
|
2111
|
+
payload["device_id"] = device_id
|
|
2112
|
+
try:
|
|
2113
|
+
_write_container_record(vmid, payload, reservation_id=reservation_id)
|
|
2114
|
+
created_record = True
|
|
2115
|
+
except Exception:
|
|
2116
|
+
_release_container_reservation(reservation_id)
|
|
2117
|
+
_cleanup_failed_container(proxmox, node, vmid, provisioning_id)
|
|
2118
|
+
raise
|
|
2119
|
+
return proxmox, node, vmid, payload
|
|
2120
|
+
|
|
2121
|
+
proxmox, _, vmid, payload_local = _run_lifecycle_step(
|
|
2122
|
+
"create_container",
|
|
2123
|
+
"Creating container",
|
|
2124
|
+
"Provisioning the LXC container…",
|
|
2125
|
+
"Container created.",
|
|
2126
|
+
_create_container,
|
|
2127
|
+
)
|
|
2004
2128
|
|
|
2005
|
-
|
|
2006
|
-
|
|
2129
|
+
def _start_container_step():
|
|
2130
|
+
_start_container(proxmox, node, vmid)
|
|
2007
2131
|
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2132
|
+
_run_lifecycle_step(
|
|
2133
|
+
"start_container",
|
|
2134
|
+
"Starting container",
|
|
2135
|
+
"Booting the container…",
|
|
2136
|
+
"Container startup completed.",
|
|
2137
|
+
_start_container_step,
|
|
2138
|
+
)
|
|
2139
|
+
_update_container_record(vmid, {"status": "running"})
|
|
2140
|
+
|
|
2141
|
+
def _bootstrap_progress_callback(
|
|
2142
|
+
step_index: int,
|
|
2143
|
+
total: int,
|
|
2144
|
+
step: Dict[str, Any],
|
|
2145
|
+
status: str,
|
|
2146
|
+
result: Optional[Dict[str, Any]],
|
|
2147
|
+
):
|
|
2148
|
+
label = step.get("display_name") or _friendly_step_label(step.get("name", "bootstrap"))
|
|
2149
|
+
error_summary = (result or {}).get("error_summary") or (result or {}).get("error")
|
|
2150
|
+
attempt = (result or {}).get("attempt")
|
|
2151
|
+
if status == "in_progress":
|
|
2152
|
+
message_text = f"{label} is running…"
|
|
2153
|
+
elif status == "completed":
|
|
2154
|
+
message_text = f"{label} completed."
|
|
2155
|
+
elif status == "retrying":
|
|
2156
|
+
attempt_desc = f" (attempt {attempt})" if attempt else ""
|
|
2157
|
+
message_text = f"{label} failed{attempt_desc}; retrying…"
|
|
2158
|
+
else:
|
|
2159
|
+
message_text = f"{label} failed"
|
|
2160
|
+
if error_summary:
|
|
2161
|
+
message_text += f": {error_summary}"
|
|
2162
|
+
details: Dict[str, Any] = {}
|
|
2163
|
+
if attempt:
|
|
2164
|
+
details["attempt"] = attempt
|
|
2165
|
+
if error_summary:
|
|
2166
|
+
details["error_summary"] = error_summary
|
|
2167
|
+
_emit_progress_event(
|
|
2168
|
+
self,
|
|
2169
|
+
step_index=step_index,
|
|
2170
|
+
total_steps=total,
|
|
2171
|
+
step_name=step.get("name", "bootstrap"),
|
|
2172
|
+
step_label=label,
|
|
2173
|
+
status=status,
|
|
2174
|
+
message=message_text,
|
|
2175
|
+
phase="bootstrap",
|
|
2176
|
+
request_id=request_id,
|
|
2177
|
+
details=details or None,
|
|
2178
|
+
on_behalf_of_device=device_id,
|
|
2179
|
+
)
|
|
2180
|
+
|
|
2181
|
+
public_key, steps = _bootstrap_portacode(
|
|
2182
|
+
vmid,
|
|
2183
|
+
payload_local["username"],
|
|
2184
|
+
payload_local["password"],
|
|
2185
|
+
payload_local["ssh_public_key"],
|
|
2186
|
+
steps=bootstrap_steps,
|
|
2187
|
+
progress_callback=_bootstrap_progress_callback,
|
|
2188
|
+
start_index=current_step_index,
|
|
2012
2189
|
total_steps=total_steps,
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
"
|
|
2021
|
-
|
|
2190
|
+
default_public_key=device_public_key if has_device_keypair else None,
|
|
2191
|
+
)
|
|
2192
|
+
current_step_index += len(bootstrap_steps)
|
|
2193
|
+
|
|
2194
|
+
service_installed = False
|
|
2195
|
+
if has_device_keypair:
|
|
2196
|
+
logger.info(
|
|
2197
|
+
"deploying dashboard-provided Portacode keypair (device_id=%s) into container %s",
|
|
2198
|
+
device_id,
|
|
2199
|
+
vmid,
|
|
2200
|
+
)
|
|
2201
|
+
_deploy_device_keypair(
|
|
2202
|
+
vmid,
|
|
2203
|
+
payload_local["username"],
|
|
2204
|
+
device_private_key,
|
|
2205
|
+
device_public_key,
|
|
2206
|
+
)
|
|
2207
|
+
service_installed = True
|
|
2208
|
+
service_start_index = current_step_index
|
|
2209
|
+
|
|
2210
|
+
auth_step_name = "setup_device_authentication"
|
|
2211
|
+
auth_label = "Setting up device authentication"
|
|
2212
|
+
_emit_progress_event(
|
|
2213
|
+
self,
|
|
2214
|
+
step_index=service_start_index,
|
|
2215
|
+
total_steps=total_steps,
|
|
2216
|
+
step_name=auth_step_name,
|
|
2217
|
+
step_label=auth_label,
|
|
2218
|
+
status="in_progress",
|
|
2219
|
+
message="Notifying the server of the new device…",
|
|
2220
|
+
phase="service",
|
|
2221
|
+
request_id=request_id,
|
|
2222
|
+
on_behalf_of_device=device_id,
|
|
2223
|
+
)
|
|
2224
|
+
_emit_progress_event(
|
|
2225
|
+
self,
|
|
2226
|
+
step_index=service_start_index,
|
|
2227
|
+
total_steps=total_steps,
|
|
2228
|
+
step_name=auth_step_name,
|
|
2229
|
+
step_label=auth_label,
|
|
2230
|
+
status="completed",
|
|
2231
|
+
message="Authentication metadata recorded.",
|
|
2232
|
+
phase="service",
|
|
2233
|
+
request_id=request_id,
|
|
2234
|
+
on_behalf_of_device=device_id,
|
|
2235
|
+
)
|
|
2236
|
+
|
|
2237
|
+
install_step = service_start_index + 1
|
|
2238
|
+
install_label = "Launching Portacode service"
|
|
2239
|
+
_emit_progress_event(
|
|
2240
|
+
self,
|
|
2241
|
+
step_index=install_step,
|
|
2242
|
+
total_steps=total_steps,
|
|
2243
|
+
step_name="launch_portacode_service",
|
|
2244
|
+
step_label=install_label,
|
|
2245
|
+
status="in_progress",
|
|
2246
|
+
message="Running sudo portacode service install…",
|
|
2247
|
+
phase="service",
|
|
2248
|
+
request_id=request_id,
|
|
2249
|
+
on_behalf_of_device=device_id,
|
|
2250
|
+
)
|
|
2251
|
+
|
|
2252
|
+
cli_path = _resolve_portacode_cli_path(vmid, payload_local["username"])
|
|
2253
|
+
cmd = _su_command(
|
|
2254
|
+
payload_local["username"],
|
|
2255
|
+
f"sudo -S {shlex.quote(cli_path)} service install",
|
|
2256
|
+
)
|
|
2257
|
+
res = _run_pct(vmid, cmd, input_text=payload_local["password"] + "\n")
|
|
2258
|
+
|
|
2259
|
+
if res["returncode"] != 0:
|
|
2260
|
+
_emit_progress_event(
|
|
2261
|
+
self,
|
|
2262
|
+
step_index=install_step,
|
|
2263
|
+
total_steps=total_steps,
|
|
2264
|
+
step_name="launch_portacode_service",
|
|
2265
|
+
step_label=install_label,
|
|
2266
|
+
status="failed",
|
|
2267
|
+
message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
|
|
2268
|
+
phase="service",
|
|
2269
|
+
request_id=request_id,
|
|
2270
|
+
details={
|
|
2271
|
+
"stderr": res.get("stderr"),
|
|
2272
|
+
"stdout": res.get("stdout"),
|
|
2273
|
+
},
|
|
2274
|
+
on_behalf_of_device=device_id,
|
|
2275
|
+
)
|
|
2276
|
+
raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
|
|
2277
|
+
|
|
2278
|
+
_emit_progress_event(
|
|
2279
|
+
self,
|
|
2280
|
+
step_index=install_step,
|
|
2281
|
+
total_steps=total_steps,
|
|
2282
|
+
step_name="launch_portacode_service",
|
|
2283
|
+
step_label=install_label,
|
|
2284
|
+
status="completed",
|
|
2285
|
+
message="Portacode service install finished.",
|
|
2286
|
+
phase="service",
|
|
2287
|
+
request_id=request_id,
|
|
2288
|
+
on_behalf_of_device=device_id,
|
|
2289
|
+
)
|
|
2290
|
+
|
|
2291
|
+
logger.info(
|
|
2292
|
+
"create_proxmox_container: portacode service install completed inside ct %s", vmid
|
|
2293
|
+
)
|
|
2294
|
+
|
|
2295
|
+
current_step_index += 2
|
|
2296
|
+
|
|
2297
|
+
_emit_host_event(
|
|
2298
|
+
self,
|
|
2299
|
+
{
|
|
2300
|
+
"event": "proxmox_container_created",
|
|
2301
|
+
"success": True,
|
|
2302
|
+
"message": f"Container {vmid} is ready and Portacode key captured.",
|
|
2303
|
+
"ctid": str(vmid),
|
|
2304
|
+
"public_key": public_key,
|
|
2305
|
+
"container": {
|
|
2306
|
+
"vmid": vmid,
|
|
2307
|
+
"hostname": payload_local["hostname"],
|
|
2308
|
+
"template": payload_local["template"],
|
|
2309
|
+
"storage": payload_local["storage"],
|
|
2310
|
+
"disk_gib": payload_local["disk_gib"],
|
|
2311
|
+
"ram_mib": payload_local["ram_mib"],
|
|
2312
|
+
"cpus": payload_local["cpus"],
|
|
2313
|
+
},
|
|
2314
|
+
"setup_steps": steps,
|
|
2315
|
+
"device_id": device_id,
|
|
2316
|
+
"on_behalf_of_device": device_id,
|
|
2317
|
+
"service_installed": service_installed,
|
|
2318
|
+
"request_id": request_id,
|
|
2319
|
+
},
|
|
2320
|
+
)
|
|
2321
|
+
except Exception as exc:
|
|
2322
|
+
if reservation_id and not created_record:
|
|
2323
|
+
_release_container_reservation(reservation_id)
|
|
2324
|
+
if vmid is not None and proxmox and node:
|
|
2325
|
+
_cleanup_failed_container(proxmox, node, vmid, provisioning_id)
|
|
2326
|
+
_remove_container_record(vmid)
|
|
2327
|
+
_emit_host_event(
|
|
2328
|
+
self,
|
|
2329
|
+
{
|
|
2330
|
+
"event": "error",
|
|
2331
|
+
"message": str(exc),
|
|
2332
|
+
"device_id": device_id,
|
|
2333
|
+
"request_id": request_id,
|
|
2022
2334
|
},
|
|
2023
|
-
on_behalf_of_device=device_id,
|
|
2024
2335
|
)
|
|
2025
|
-
raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
|
|
2026
|
-
|
|
2027
|
-
_emit_progress_event(
|
|
2028
|
-
self,
|
|
2029
|
-
step_index=install_step,
|
|
2030
|
-
total_steps=total_steps,
|
|
2031
|
-
step_name="launch_portacode_service",
|
|
2032
|
-
step_label=install_label,
|
|
2033
|
-
status="completed",
|
|
2034
|
-
message="Portacode service install finished.",
|
|
2035
|
-
phase="service",
|
|
2036
|
-
request_id=request_id,
|
|
2037
|
-
on_behalf_of_device=device_id,
|
|
2038
|
-
)
|
|
2039
|
-
|
|
2040
|
-
logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
|
|
2041
2336
|
|
|
2042
|
-
|
|
2337
|
+
threading.Thread(target=_provision_background, daemon=True).start()
|
|
2043
2338
|
|
|
2044
2339
|
return {
|
|
2045
|
-
"event": "
|
|
2340
|
+
"event": "proxmox_container_accepted",
|
|
2046
2341
|
"success": True,
|
|
2047
|
-
"message":
|
|
2048
|
-
"ctid": str(vmid),
|
|
2049
|
-
"public_key": public_key,
|
|
2050
|
-
"container": {
|
|
2051
|
-
"vmid": vmid,
|
|
2052
|
-
"hostname": payload["hostname"],
|
|
2053
|
-
"template": payload["template"],
|
|
2054
|
-
"storage": payload["storage"],
|
|
2055
|
-
"disk_gib": payload["disk_gib"],
|
|
2056
|
-
"ram_mib": payload["ram_mib"],
|
|
2057
|
-
"cpus": payload["cpus"],
|
|
2058
|
-
},
|
|
2059
|
-
"setup_steps": steps,
|
|
2342
|
+
"message": "Provisioning accepted; resources reserved.",
|
|
2060
2343
|
"device_id": device_id,
|
|
2061
|
-
"
|
|
2062
|
-
"service_installed": service_installed,
|
|
2344
|
+
"request_id": request_id,
|
|
2063
2345
|
}
|
|
2064
2346
|
|
|
2065
2347
|
|
|
@@ -2134,7 +2416,8 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
2134
2416
|
on_behalf_of_device=on_behalf_of_device,
|
|
2135
2417
|
)
|
|
2136
2418
|
|
|
2137
|
-
|
|
2419
|
+
cli_path = _resolve_portacode_cli_path(vmid, user)
|
|
2420
|
+
cmd = _su_command(user, f"sudo -S {shlex.quote(cli_path)} service install")
|
|
2138
2421
|
res = _run_pct(vmid, cmd, input_text=password + "\n")
|
|
2139
2422
|
|
|
2140
2423
|
if res["returncode"] != 0:
|
portacode/service.py
CHANGED
|
@@ -6,6 +6,7 @@ runs ``portacode connect`` automatically at login / boot.
|
|
|
6
6
|
Platforms implemented:
|
|
7
7
|
|
|
8
8
|
• Linux (systemd **user** service) – no root privileges required
|
|
9
|
+
• Linux (OpenRC service) – system-wide (e.g., Alpine)
|
|
9
10
|
• macOS (launchd LaunchAgent plist) – per-user
|
|
10
11
|
• Windows (Task Scheduler *ONLOGON* task) – highest privilege, current user
|
|
11
12
|
|
|
@@ -26,6 +27,7 @@ import os
|
|
|
26
27
|
from typing import Protocol
|
|
27
28
|
import shutil
|
|
28
29
|
import pwd
|
|
30
|
+
import tempfile
|
|
29
31
|
|
|
30
32
|
__all__ = [
|
|
31
33
|
"ServiceManager",
|
|
@@ -199,6 +201,129 @@ class _SystemdUserService:
|
|
|
199
201
|
return (status or "") + "\n--- recent logs ---\n" + (journal or "<no logs>")
|
|
200
202
|
|
|
201
203
|
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Linux – OpenRC implementation (e.g., Alpine)
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
class _OpenRCService:
|
|
208
|
+
NAME = "portacode"
|
|
209
|
+
|
|
210
|
+
def __init__(self) -> None:
|
|
211
|
+
self.init_path = Path("/etc/init.d") / self.NAME
|
|
212
|
+
self.wrapper_path = Path("/usr/local/share/portacode/connect_service.sh")
|
|
213
|
+
self.user = os.environ.get("SUDO_USER") or os.environ.get("USER") or os.getlogin()
|
|
214
|
+
try:
|
|
215
|
+
self.home = Path(pwd.getpwnam(self.user).pw_dir)
|
|
216
|
+
except KeyError:
|
|
217
|
+
self.home = Path("/root") if self.user == "root" else Path(f"/home/{self.user}")
|
|
218
|
+
self.python = shutil.which("python3") or sys.executable
|
|
219
|
+
self.log_dir = Path("/var/log/portacode")
|
|
220
|
+
self.log_path = self.log_dir / "connect.log"
|
|
221
|
+
|
|
222
|
+
def _run(self, *args: str) -> subprocess.CompletedProcess[str]:
|
|
223
|
+
prefix = ["sudo"] if os.geteuid() != 0 else []
|
|
224
|
+
cmd = [*prefix, *args]
|
|
225
|
+
return subprocess.run(cmd, text=True, capture_output=True)
|
|
226
|
+
|
|
227
|
+
def _write_init_script(self) -> None:
|
|
228
|
+
self._write_wrapper_script()
|
|
229
|
+
script = textwrap.dedent(f"""
|
|
230
|
+
#!/sbin/openrc-run
|
|
231
|
+
description="Portacode persistent connection"
|
|
232
|
+
|
|
233
|
+
command="{self.wrapper_path}"
|
|
234
|
+
command_user="{self.user}"
|
|
235
|
+
command_background="yes"
|
|
236
|
+
pidfile="/run/portacode.pid"
|
|
237
|
+
directory="{self.home}"
|
|
238
|
+
|
|
239
|
+
depend() {{
|
|
240
|
+
need net
|
|
241
|
+
}}
|
|
242
|
+
|
|
243
|
+
start_pre() {{
|
|
244
|
+
checkpath --directory --mode 0755 /var/log/portacode
|
|
245
|
+
checkpath --directory --mode 0755 /usr/local/share/portacode
|
|
246
|
+
touch "{self.log_path}"
|
|
247
|
+
chown {self.user} "{self.log_path}"
|
|
248
|
+
chown {self.user} /var/log/portacode
|
|
249
|
+
}}
|
|
250
|
+
""").lstrip()
|
|
251
|
+
|
|
252
|
+
tmp_path = Path(tempfile.gettempdir()) / f"portacode-init-{os.getpid()}"
|
|
253
|
+
tmp_path.write_text(script)
|
|
254
|
+
if os.geteuid() != 0:
|
|
255
|
+
self._run("install", "-m", "755", str(tmp_path), str(self.init_path))
|
|
256
|
+
else:
|
|
257
|
+
self.init_path.parent.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
shutil.copyfile(tmp_path, self.init_path)
|
|
259
|
+
self.init_path.chmod(0o755)
|
|
260
|
+
try:
|
|
261
|
+
tmp_path.unlink()
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
def _write_wrapper_script(self) -> None:
|
|
266
|
+
script = textwrap.dedent(f"""
|
|
267
|
+
#!/bin/sh
|
|
268
|
+
cd "{self.home}"
|
|
269
|
+
exec "{self.python}" -m portacode connect --non-interactive >> "{self.log_path}" 2>&1
|
|
270
|
+
""").lstrip()
|
|
271
|
+
tmp_path = Path(tempfile.gettempdir()) / f"portacode-wrapper-{os.getpid()}"
|
|
272
|
+
tmp_path.write_text(script)
|
|
273
|
+
if os.geteuid() != 0:
|
|
274
|
+
self._run("install", "-m", "755", str(tmp_path), str(self.wrapper_path))
|
|
275
|
+
else:
|
|
276
|
+
self.wrapper_path.parent.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
shutil.copyfile(tmp_path, self.wrapper_path)
|
|
278
|
+
self.wrapper_path.chmod(0o755)
|
|
279
|
+
try:
|
|
280
|
+
tmp_path.unlink()
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
def install(self) -> None:
|
|
285
|
+
self._write_init_script()
|
|
286
|
+
self._run("rc-update", "add", self.NAME, "default")
|
|
287
|
+
self._run("rc-service", self.NAME, "start")
|
|
288
|
+
|
|
289
|
+
def uninstall(self) -> None:
|
|
290
|
+
self._run("rc-service", self.NAME, "stop")
|
|
291
|
+
self._run("rc-update", "del", self.NAME, "default")
|
|
292
|
+
if self.init_path.exists():
|
|
293
|
+
if os.geteuid() != 0:
|
|
294
|
+
self._run("rm", "-f", str(self.init_path))
|
|
295
|
+
else:
|
|
296
|
+
self.init_path.unlink()
|
|
297
|
+
|
|
298
|
+
def start(self) -> None:
|
|
299
|
+
self._run("rc-service", self.NAME, "start")
|
|
300
|
+
|
|
301
|
+
def stop(self) -> None:
|
|
302
|
+
self._run("rc-service", self.NAME, "stop")
|
|
303
|
+
|
|
304
|
+
def status(self) -> str:
|
|
305
|
+
res = self._run("rc-service", self.NAME, "status")
|
|
306
|
+
out = (res.stdout or res.stderr or "").strip().lower()
|
|
307
|
+
if "started" in out or "running" in out:
|
|
308
|
+
return "running"
|
|
309
|
+
if "stopped" in out:
|
|
310
|
+
return "stopped"
|
|
311
|
+
return out or "unknown"
|
|
312
|
+
|
|
313
|
+
def status_verbose(self) -> str:
|
|
314
|
+
res = self._run("rc-service", self.NAME, "status")
|
|
315
|
+
status = res.stdout or res.stderr or ""
|
|
316
|
+
log_tail = "<no logs>"
|
|
317
|
+
try:
|
|
318
|
+
if self.log_path.exists():
|
|
319
|
+
with self.log_path.open("r", encoding="utf-8", errors="ignore") as fh:
|
|
320
|
+
lines = fh.readlines()
|
|
321
|
+
log_tail = "".join(lines[-20:]) or "<no logs>"
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
return (status or "").rstrip() + "\n--- recent logs ---\n" + log_tail
|
|
325
|
+
|
|
326
|
+
|
|
202
327
|
# ---------------------------------------------------------------------------
|
|
203
328
|
# macOS – launchd (LaunchAgent) implementation
|
|
204
329
|
# ---------------------------------------------------------------------------
|
|
@@ -422,9 +547,13 @@ class _WindowsTask:
|
|
|
422
547
|
def get_manager(system_mode: bool = False) -> ServiceManager:
|
|
423
548
|
system = platform.system().lower()
|
|
424
549
|
if system == "linux":
|
|
425
|
-
|
|
550
|
+
if shutil.which("systemctl"):
|
|
551
|
+
return _SystemdUserService(system_mode=system_mode) # type: ignore[return-value]
|
|
552
|
+
if shutil.which("rc-service") or Path("/sbin/openrc").exists():
|
|
553
|
+
return _OpenRCService() # type: ignore[return-value]
|
|
554
|
+
raise RuntimeError("Unsupported Linux init system (no systemctl or rc-service found)")
|
|
426
555
|
if system == "darwin":
|
|
427
556
|
return _LaunchdService() # type: ignore[return-value]
|
|
428
557
|
if system.startswith("windows") or system == "windows":
|
|
429
558
|
return _WindowsTask() # type: ignore[return-value]
|
|
430
|
-
raise RuntimeError(f"Unsupported platform: {system}")
|
|
559
|
+
raise RuntimeError(f"Unsupported platform: {system}")
|
|
@@ -1,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=8lzbms9BpD-Oht4SHc7EY9GwLJHe1VkS9qzxWPibYsY,706
|
|
5
5
|
portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
|
|
6
6
|
portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
|
|
7
7
|
portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
|
|
8
8
|
portacode/logging_categories.py,sha256=9m-BYrjyHh1vjZYBQT4JhAh6b_oYUhIWayO-noH1cSE,5063
|
|
9
9
|
portacode/pairing.py,sha256=OzSuc0GhrknrDrny4aBU6IUnmKzRDTtocuDpyaVnyrs,3116
|
|
10
|
-
portacode/service.py,sha256=
|
|
10
|
+
portacode/service.py,sha256=8dX9bmJ97r8UuM4PJMICQ_D8kWpMPRIdTgPJfsOKmrM,21617
|
|
11
11
|
portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJg,883
|
|
12
12
|
portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
|
|
13
13
|
portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
|
|
@@ -22,7 +22,7 @@ portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZb
|
|
|
22
22
|
portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
|
|
23
23
|
portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
|
|
24
24
|
portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
|
|
25
|
-
portacode/connection/handlers/proxmox_infra.py,sha256
|
|
25
|
+
portacode/connection/handlers/proxmox_infra.py,sha256=6Av5QeNQGh38U0kRzoB4ZICmSrRaWLT3YXHoAqUisv4,99102
|
|
26
26
|
portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
|
|
27
27
|
portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
|
|
28
28
|
portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
|
|
@@ -65,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
|
|
|
65
65
|
portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
|
|
66
66
|
portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
|
|
67
67
|
portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
|
|
68
|
-
portacode-1.4.
|
|
68
|
+
portacode-1.4.17.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
|
|
69
69
|
test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
|
|
70
70
|
test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
|
|
71
71
|
test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
|
|
@@ -91,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
|
|
|
91
91
|
testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
|
|
92
92
|
testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
|
|
93
93
|
testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
|
|
94
|
-
portacode-1.4.
|
|
95
|
-
portacode-1.4.
|
|
96
|
-
portacode-1.4.
|
|
97
|
-
portacode-1.4.
|
|
98
|
-
portacode-1.4.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|