portacode 1.4.12.dev1__py3-none-any.whl → 1.4.15.dev10__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/WEBSOCKET_PROTOCOL.md +15 -0
- portacode/connection/handlers/proxmox_infra.py +353 -36
- portacode/connection/handlers/system_handlers.py +131 -2
- portacode/connection/handlers/test_proxmox_infra.py +13 -0
- portacode/connection/terminal.py +6 -0
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev10.dist-info}/METADATA +1 -1
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev10.dist-info}/RECORD +12 -11
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev10.dist-info}/WHEEL +1 -1
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev10.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev10.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.12.dev1.dist-info → portacode-1.4.15.dev10.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.15.dev10'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 4, 15, 'dev10')
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -27,6 +27,10 @@ The Portacode server acts as a **routing middleman** between client sessions and
|
|
|
27
27
|
|
|
28
28
|
- **`source_client_session`** (Server → Device): Server **adds this** when forwarding client commands to devices (so device knows which client sent the command and can target responses back). Clients never include this field.
|
|
29
29
|
|
|
30
|
+
### Proxying infrastructure updates
|
|
31
|
+
|
|
32
|
+
Portacode infrastructure devices (like the proxmox host) can send events on behalf of the LXC Devices they manage. Such messages include the optional `on_behalf_of_device` field and the server silently replaces `device_id` with that child device before routing. The gateway enforces that the sender is the child’s `proxmox_parent` (via `Device.proxmox_parent`) so only the infrastructure owner can impersonate a child device. Messages that fail this check are dropped.
|
|
33
|
+
|
|
30
34
|
This document describes the complete protocol for communicating with devices through the server, guiding app developers on how to get their client sessions to communicate with devices.
|
|
31
35
|
|
|
32
36
|
## Table of Contents
|
|
@@ -361,6 +365,9 @@ Creates a Portacode-managed LXC container, starts it, and bootstraps the Portaco
|
|
|
361
365
|
* `username` (string, optional): OS user to provision (defaults to `svcuser`).
|
|
362
366
|
* `password` (string, optional): Password for the user (used only during provisioning).
|
|
363
367
|
* `ssh_key` (string, optional): SSH public key to add to the user.
|
|
368
|
+
* `device_id` (string, required): ID of the dashboard Device record that represents the container. The handler persists this value in the host metadata file so related events can always be correlated back to that Device.
|
|
369
|
+
* `device_public_key` (string, optional): PEM-encoded Portacode public key. When supplied together with `device_private_key` the handler injects the keypair, records the device metadata, and runs `portacode service install` automatically.
|
|
370
|
+
* `device_private_key` (string, optional): PEM-encoded private key that pairs with `device_public_key`. Both key fields must be present for the automatic service-install mode.
|
|
364
371
|
|
|
365
372
|
**Responses:**
|
|
366
373
|
|
|
@@ -418,6 +425,9 @@ Emitted after a successful `create_proxmox_container` action. Contains the new c
|
|
|
418
425
|
* `public_key` (string): Portacode public auth key created inside the new container.
|
|
419
426
|
* `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
|
|
420
427
|
* `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
|
|
428
|
+
* `device_id` (string): Mirrors the dashboard Device ID supplied with `create_proxmox_container`. The handler records this value in the container metadata file so subsequent events can reference the same Device.
|
|
429
|
+
* `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device; only proxmox parents may include this field.
|
|
430
|
+
* `service_installed` (boolean): True when the handler already ran `portacode service install` (with a provided keypair); otherwise it remains False and the dashboard can call `start_portacode_service`.
|
|
421
431
|
|
|
422
432
|
### `proxmox_container_progress`
|
|
423
433
|
|
|
@@ -434,6 +444,7 @@ Sent intermittently while `create_proxmox_container` is executing so callers can
|
|
|
434
444
|
* `message` (string): Short description of what is happening or why a failure occurred.
|
|
435
445
|
* `details` (object, optional): Contains `attempt` (if retries were needed) and `error_summary` when a step fails.
|
|
436
446
|
* `request_id` (string, optional): Mirrors the request ID from the incoming `create_proxmox_container` payload when available.
|
|
447
|
+
* `on_behalf_of_device` (string, optional): When present the proxmox device is reporting progress for the referenced dashboard device; the gateway verifies the proxmox node is the child’s `proxmox_parent` before routing the event.
|
|
437
448
|
|
|
438
449
|
### `start_portacode_service`
|
|
439
450
|
|
|
@@ -450,6 +461,7 @@ Runs `sudo portacode service install` inside the container after the dashboard h
|
|
|
450
461
|
* Emits additional [`proxmox_container_progress`](#proxmox_container_progress-event) events to report the authentication and service-install steps.
|
|
451
462
|
* On success, emits a [`proxmox_service_started`](#proxmox_service_started-event).
|
|
452
463
|
* On failure, emits a generic [`error`](#error) event.
|
|
464
|
+
* When `create_proxmox_container` already provided a dashboard-generated keypair, the handler may have installed the service already, so this call is optional unless you need to re-run the install.
|
|
453
465
|
|
|
454
466
|
### `proxmox_service_started`
|
|
455
467
|
|
|
@@ -1171,6 +1183,8 @@ Emitted after a successful `create_proxmox_container` action to report the newly
|
|
|
1171
1183
|
* `public_key` (string): Portacode public auth key discovered inside the container.
|
|
1172
1184
|
* `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
|
|
1173
1185
|
* `setup_steps` (array[object]): Detailed bootstrap step reports including stdout/stderr, elapsed time, and pass/fail status.
|
|
1186
|
+
* `device_id` (string): Mirrors the device ID supplied with `create_proxmox_container` and persisted inside the host metadata file for this CT.
|
|
1187
|
+
* `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device.
|
|
1174
1188
|
|
|
1175
1189
|
### `proxmox_container_progress`
|
|
1176
1190
|
|
|
@@ -1187,6 +1201,7 @@ Sent continuously while `create_proxmox_container` runs so dashboards can show a
|
|
|
1187
1201
|
* `message` (string): Short human-readable description of the action or failure.
|
|
1188
1202
|
* `details` (object, optional): Contains `attempt` (when retries are used) and `error_summary` on failure.
|
|
1189
1203
|
* `request_id` (string, optional): Mirrors the `create_proxmox_container` request when provided.
|
|
1204
|
+
* `on_behalf_of_device` (string, optional): Mirrors the child device ID when a proxmox host reports progress for that child; only proxmox parents can supply this field.
|
|
1190
1205
|
|
|
1191
1206
|
### `proxmox_container_action`
|
|
1192
1207
|
|
|
@@ -5,17 +5,20 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
|
+
import math
|
|
8
9
|
import os
|
|
9
10
|
import secrets
|
|
11
|
+
import shlex
|
|
10
12
|
import shutil
|
|
11
13
|
import stat
|
|
12
14
|
import subprocess
|
|
13
15
|
import sys
|
|
16
|
+
import tempfile
|
|
14
17
|
import time
|
|
15
18
|
import threading
|
|
16
19
|
from datetime import datetime
|
|
17
20
|
from pathlib import Path
|
|
18
|
-
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
|
21
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
19
22
|
|
|
20
23
|
import platformdirs
|
|
21
24
|
|
|
@@ -44,6 +47,7 @@ UNIT_DIR = Path("/etc/systemd/system")
|
|
|
44
47
|
_MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
|
|
45
48
|
_MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
|
|
46
49
|
_MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
|
|
50
|
+
_STARTUP_TEMPLATES_REFRESHED = False
|
|
47
51
|
|
|
48
52
|
ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
|
|
49
53
|
|
|
@@ -60,6 +64,7 @@ def _emit_progress_event(
|
|
|
60
64
|
phase: str,
|
|
61
65
|
request_id: Optional[str],
|
|
62
66
|
details: Optional[Dict[str, Any]] = None,
|
|
67
|
+
on_behalf_of_device: Optional[str] = None,
|
|
63
68
|
) -> None:
|
|
64
69
|
loop = handler.context.get("event_loop")
|
|
65
70
|
if not loop or loop.is_closed():
|
|
@@ -84,6 +89,8 @@ def _emit_progress_event(
|
|
|
84
89
|
payload["request_id"] = request_id
|
|
85
90
|
if details:
|
|
86
91
|
payload["details"] = details
|
|
92
|
+
if on_behalf_of_device:
|
|
93
|
+
payload["on_behalf_of_device"] = str(on_behalf_of_device)
|
|
87
94
|
|
|
88
95
|
future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
|
|
89
96
|
future.add_done_callback(
|
|
@@ -173,6 +180,41 @@ def _list_templates(client: Any, node: str, storages: Iterable[Dict[str, Any]])
|
|
|
173
180
|
return templates
|
|
174
181
|
|
|
175
182
|
|
|
183
|
+
def _build_proxmox_client_from_config(config: Dict[str, Any]):
|
|
184
|
+
user = config.get("user")
|
|
185
|
+
token_name = config.get("token_name")
|
|
186
|
+
token_value = config.get("token_value")
|
|
187
|
+
if not user or not token_name or not token_value:
|
|
188
|
+
raise RuntimeError("Proxmox API credentials are missing")
|
|
189
|
+
ProxmoxAPI = _ensure_proxmoxer()
|
|
190
|
+
return ProxmoxAPI(
|
|
191
|
+
config.get("host", DEFAULT_HOST),
|
|
192
|
+
user=user,
|
|
193
|
+
token_name=token_name,
|
|
194
|
+
token_value=token_value,
|
|
195
|
+
verify_ssl=config.get("verify_ssl", False),
|
|
196
|
+
timeout=30,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
|
|
201
|
+
global _STARTUP_TEMPLATES_REFRESHED
|
|
202
|
+
if _STARTUP_TEMPLATES_REFRESHED or not config or not config.get("token_value"):
|
|
203
|
+
_STARTUP_TEMPLATES_REFRESHED = True
|
|
204
|
+
return
|
|
205
|
+
try:
|
|
206
|
+
client = _build_proxmox_client_from_config(config)
|
|
207
|
+
node = config.get("node") or _pick_node(client)
|
|
208
|
+
storages = client.nodes(node).storage.get()
|
|
209
|
+
templates = _list_templates(client, node, storages)
|
|
210
|
+
if templates:
|
|
211
|
+
config["templates"] = templates
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
|
|
214
|
+
finally:
|
|
215
|
+
_STARTUP_TEMPLATES_REFRESHED = True
|
|
216
|
+
|
|
217
|
+
|
|
176
218
|
def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
|
|
177
219
|
candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
|
|
178
220
|
if not candidates:
|
|
@@ -261,7 +303,10 @@ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
|
|
|
261
303
|
apt = shutil.which("apt-get")
|
|
262
304
|
if not apt:
|
|
263
305
|
raise RuntimeError("dnsmasq is missing and apt-get unavailable to install it")
|
|
264
|
-
_call_subprocess([apt, "update"], check=
|
|
306
|
+
update = _call_subprocess([apt, "update"], check=False)
|
|
307
|
+
if update.returncode not in (0, 100):
|
|
308
|
+
msg = update.stderr or update.stdout or f"exit status {update.returncode}"
|
|
309
|
+
raise RuntimeError(f"apt-get update failed: {msg}")
|
|
265
310
|
_call_subprocess([apt, "install", "-y", "dnsmasq"], check=True)
|
|
266
311
|
_write_bridge_config(bridge)
|
|
267
312
|
_ensure_sysctl()
|
|
@@ -428,7 +473,12 @@ def _friendly_step_label(step_name: str) -> str:
|
|
|
428
473
|
return normalized.capitalize()
|
|
429
474
|
|
|
430
475
|
|
|
431
|
-
def _build_bootstrap_steps(
|
|
476
|
+
def _build_bootstrap_steps(
|
|
477
|
+
user: str,
|
|
478
|
+
password: str,
|
|
479
|
+
ssh_key: str,
|
|
480
|
+
include_portacode_connect: bool = True,
|
|
481
|
+
) -> List[Dict[str, Any]]:
|
|
432
482
|
steps = [
|
|
433
483
|
{
|
|
434
484
|
"name": "apt_update",
|
|
@@ -465,11 +515,14 @@ def _build_bootstrap_steps(user: str, password: str, ssh_key: str) -> List[Dict[
|
|
|
465
515
|
"cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
|
|
466
516
|
"retries": 0,
|
|
467
517
|
})
|
|
468
|
-
steps.extend(
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
518
|
+
steps.extend(
|
|
519
|
+
[
|
|
520
|
+
{"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
|
|
521
|
+
{"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
|
|
522
|
+
]
|
|
523
|
+
)
|
|
524
|
+
if include_portacode_connect:
|
|
525
|
+
steps.append({"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30})
|
|
473
526
|
return steps
|
|
474
527
|
|
|
475
528
|
|
|
@@ -590,7 +643,7 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
|
|
|
590
643
|
hostname = (message.get("hostname") or "").strip()
|
|
591
644
|
disk_gib = int(max(round(_validate_positive_number(message.get("disk_gib") or message.get("disk"), 3)), 1))
|
|
592
645
|
ram_mib = int(max(round(_validate_positive_number(message.get("ram_mib") or message.get("ram"), 2048)), 1))
|
|
593
|
-
cpus = _validate_positive_number(message.get("cpus"),
|
|
646
|
+
cpus = _validate_positive_number(message.get("cpus"), 0.2)
|
|
594
647
|
storage = message.get("storage") or config.get("default_storage") or ""
|
|
595
648
|
if not storage:
|
|
596
649
|
raise ValueError("Storage pool could not be determined.")
|
|
@@ -679,6 +732,74 @@ def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
|
|
|
679
732
|
return res
|
|
680
733
|
|
|
681
734
|
|
|
735
|
+
def _run_pct_exec(vmid: int, command: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
|
736
|
+
return _call_subprocess(["pct", "exec", str(vmid), "--", *command])
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _run_pct_exec_check(vmid: int, command: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
|
740
|
+
res = _run_pct_exec(vmid, command)
|
|
741
|
+
if res.returncode != 0:
|
|
742
|
+
raise RuntimeError(res.stderr or res.stdout or f"pct exec {' '.join(command)} failed")
|
|
743
|
+
return res
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _run_pct_push(vmid: int, src: str, dest: str) -> subprocess.CompletedProcess[str]:
|
|
747
|
+
return _call_subprocess(["pct", "push", str(vmid), src, dest])
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _push_bytes_to_container(
|
|
751
|
+
vmid: int, user: str, path: str, data: bytes, mode: int = 0o600
|
|
752
|
+
) -> None:
|
|
753
|
+
logger.debug("Preparing to push %d bytes to container vmid=%s path=%s for user=%s", len(data), vmid, path, user)
|
|
754
|
+
tmp_path: Optional[str] = None
|
|
755
|
+
try:
|
|
756
|
+
parent = Path(path).parent
|
|
757
|
+
parent_str = parent.as_posix()
|
|
758
|
+
if parent_str not in {"", ".", "/"}:
|
|
759
|
+
_run_pct_exec_check(vmid, ["mkdir", "-p", parent_str])
|
|
760
|
+
_run_pct_exec_check(vmid, ["chown", "-R", f"{user}:{user}", parent_str])
|
|
761
|
+
|
|
762
|
+
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
|
763
|
+
tmp.write(data)
|
|
764
|
+
tmp.flush()
|
|
765
|
+
os.fsync(tmp.fileno())
|
|
766
|
+
tmp_path = tmp.name
|
|
767
|
+
|
|
768
|
+
push_res = _run_pct_push(vmid, tmp_path, path)
|
|
769
|
+
if push_res.returncode != 0:
|
|
770
|
+
raise RuntimeError(push_res.stderr or push_res.stdout or f"pct push returned {push_res.returncode}")
|
|
771
|
+
|
|
772
|
+
_run_pct_exec_check(vmid, ["chown", f"{user}:{user}", path])
|
|
773
|
+
_run_pct_exec_check(vmid, ["chmod", format(mode, "o"), path])
|
|
774
|
+
logger.debug("Successfully pushed %d bytes to vmid=%s path=%s", len(data), vmid, path)
|
|
775
|
+
except Exception as exc:
|
|
776
|
+
logger.error("Failed to write to container vmid=%s path=%s for user=%s: %s", vmid, path, user, exc)
|
|
777
|
+
raise
|
|
778
|
+
finally:
|
|
779
|
+
if tmp_path:
|
|
780
|
+
try:
|
|
781
|
+
os.remove(tmp_path)
|
|
782
|
+
except OSError as cleanup_exc:
|
|
783
|
+
logger.warning("Failed to remove temporary file %s: %s", tmp_path, cleanup_exc)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _resolve_portacode_key_dir(vmid: int, user: str) -> str:
|
|
787
|
+
data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
|
|
788
|
+
data_home = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
|
|
789
|
+
portacode_dir = f"{data_home}/portacode"
|
|
790
|
+
_run_pct_exec_check(vmid, ["mkdir", "-p", portacode_dir])
|
|
791
|
+
_run_pct_exec_check(vmid, ["chown", "-R", f"{user}:{user}", portacode_dir])
|
|
792
|
+
return f"{portacode_dir}/keys"
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _deploy_device_keypair(vmid: int, user: str, private_key: str, public_key: str) -> None:
|
|
796
|
+
key_dir = _resolve_portacode_key_dir(vmid, user)
|
|
797
|
+
priv_path = f"{key_dir}/id_portacode"
|
|
798
|
+
pub_path = f"{key_dir}/id_portacode.pub"
|
|
799
|
+
_push_bytes_to_container(vmid, user, priv_path, private_key.encode(), mode=0o600)
|
|
800
|
+
_push_bytes_to_container(vmid, user, pub_path, public_key.encode(), mode=0o644)
|
|
801
|
+
|
|
802
|
+
|
|
682
803
|
def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
|
|
683
804
|
cmd = ["pct", "exec", str(vmid), "--", "bash", "-lc", f"su - {user} -c 'portacode connect'"]
|
|
684
805
|
proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
@@ -702,15 +823,22 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
|
|
|
702
823
|
|
|
703
824
|
last_pub = last_priv = None
|
|
704
825
|
stable = 0
|
|
826
|
+
history: List[Dict[str, Any]] = []
|
|
827
|
+
|
|
828
|
+
process_exited = False
|
|
829
|
+
exit_out = exit_err = ""
|
|
705
830
|
while time.time() - start < timeout_s:
|
|
706
831
|
if proc.poll() is not None:
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
832
|
+
process_exited = True
|
|
833
|
+
exit_out, exit_err = proc.communicate(timeout=1)
|
|
834
|
+
history.append(
|
|
835
|
+
{
|
|
836
|
+
"timestamp_s": round(time.time() - start, 2),
|
|
837
|
+
"status": "process_exited",
|
|
838
|
+
"returncode": proc.returncode,
|
|
839
|
+
}
|
|
840
|
+
)
|
|
841
|
+
break
|
|
714
842
|
pub_size = file_size(pub_path)
|
|
715
843
|
priv_size = file_size(priv_path)
|
|
716
844
|
if pub_size and priv_size:
|
|
@@ -720,21 +848,60 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
|
|
|
720
848
|
stable = 0
|
|
721
849
|
last_pub, last_priv = pub_size, priv_size
|
|
722
850
|
if stable >= 1:
|
|
851
|
+
history.append(
|
|
852
|
+
{
|
|
853
|
+
"timestamp_s": round(time.time() - start, 2),
|
|
854
|
+
"pub_size": pub_size,
|
|
855
|
+
"priv_size": priv_size,
|
|
856
|
+
"stable": stable,
|
|
857
|
+
}
|
|
858
|
+
)
|
|
723
859
|
break
|
|
860
|
+
history.append(
|
|
861
|
+
{
|
|
862
|
+
"timestamp_s": round(time.time() - start, 2),
|
|
863
|
+
"pub_size": pub_size,
|
|
864
|
+
"priv_size": priv_size,
|
|
865
|
+
"stable": stable,
|
|
866
|
+
}
|
|
867
|
+
)
|
|
724
868
|
time.sleep(1)
|
|
725
869
|
|
|
726
|
-
|
|
870
|
+
final_pub = file_size(pub_path)
|
|
871
|
+
final_priv = file_size(priv_path)
|
|
872
|
+
if final_pub and final_priv:
|
|
873
|
+
key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
|
|
874
|
+
if not process_exited:
|
|
875
|
+
proc.terminate()
|
|
876
|
+
try:
|
|
877
|
+
proc.wait(timeout=3)
|
|
878
|
+
except subprocess.TimeoutExpired:
|
|
879
|
+
proc.kill()
|
|
880
|
+
return {
|
|
881
|
+
"ok": True,
|
|
882
|
+
"public_key": key_res["stdout"].strip(),
|
|
883
|
+
"history": history,
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if not process_exited:
|
|
727
887
|
proc.terminate()
|
|
728
888
|
try:
|
|
729
889
|
proc.wait(timeout=3)
|
|
730
890
|
except subprocess.TimeoutExpired:
|
|
731
891
|
proc.kill()
|
|
732
|
-
|
|
892
|
+
exit_out, exit_err = proc.communicate(timeout=1)
|
|
893
|
+
history.append(
|
|
894
|
+
{
|
|
895
|
+
"timestamp_s": round(time.time() - start, 2),
|
|
896
|
+
"status": "timeout_waiting_for_keys",
|
|
897
|
+
}
|
|
898
|
+
)
|
|
733
899
|
return {
|
|
734
900
|
"ok": False,
|
|
735
901
|
"error": "timed out waiting for portacode key files",
|
|
736
|
-
"stdout": (
|
|
737
|
-
"stderr": (
|
|
902
|
+
"stdout": (exit_out or "").strip(),
|
|
903
|
+
"stderr": (exit_err or "").strip(),
|
|
904
|
+
"history": history,
|
|
738
905
|
}
|
|
739
906
|
|
|
740
907
|
proc.terminate()
|
|
@@ -747,6 +914,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
|
|
|
747
914
|
return {
|
|
748
915
|
"ok": True,
|
|
749
916
|
"public_key": key_res["stdout"].strip(),
|
|
917
|
+
"history": history,
|
|
750
918
|
}
|
|
751
919
|
|
|
752
920
|
|
|
@@ -833,6 +1001,7 @@ def _bootstrap_portacode(
|
|
|
833
1001
|
progress_callback: Optional[ProgressCallback] = None,
|
|
834
1002
|
start_index: int = 1,
|
|
835
1003
|
total_steps: Optional[int] = None,
|
|
1004
|
+
default_public_key: Optional[str] = None,
|
|
836
1005
|
) -> Tuple[str, List[Dict[str, Any]]]:
|
|
837
1006
|
actual_steps = steps if steps is not None else _build_bootstrap_steps(user, password, ssh_key)
|
|
838
1007
|
results, ok = _run_setup_steps(
|
|
@@ -844,9 +1013,33 @@ def _bootstrap_portacode(
|
|
|
844
1013
|
total_steps=total_steps,
|
|
845
1014
|
)
|
|
846
1015
|
if not ok:
|
|
1016
|
+
details = results[-1] if results else {}
|
|
1017
|
+
summary = details.get("error_summary") or details.get("stderr") or details.get("stdout") or details.get("name")
|
|
1018
|
+
history = details.get("history")
|
|
1019
|
+
history_snippet = ""
|
|
1020
|
+
if isinstance(history, list) and history:
|
|
1021
|
+
history_snippet = f" history={history[-3:]}"
|
|
1022
|
+
command = details.get("cmd")
|
|
1023
|
+
command_text = ""
|
|
1024
|
+
if command:
|
|
1025
|
+
if isinstance(command, (list, tuple)):
|
|
1026
|
+
command_text = shlex.join(str(entry) for entry in command)
|
|
1027
|
+
else:
|
|
1028
|
+
command_text = str(command)
|
|
1029
|
+
command_suffix = f" command={command_text}" if command_text else ""
|
|
1030
|
+
if summary:
|
|
1031
|
+
logger.warning(
|
|
1032
|
+
"Portacode bootstrap failure summary=%s%s%s",
|
|
1033
|
+
summary,
|
|
1034
|
+
f" history_len={len(history)}" if history else "",
|
|
1035
|
+
f" command={command_text}" if command_text else "",
|
|
1036
|
+
)
|
|
1037
|
+
raise RuntimeError(
|
|
1038
|
+
f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
|
|
1039
|
+
)
|
|
847
1040
|
raise RuntimeError("Portacode bootstrap steps failed.")
|
|
848
1041
|
key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
|
|
849
|
-
public_key = key_step.get("public_key") if key_step else
|
|
1042
|
+
public_key = key_step.get("public_key") if key_step else default_public_key
|
|
850
1043
|
if not public_key:
|
|
851
1044
|
raise RuntimeError("Portacode connect did not return a public key.")
|
|
852
1045
|
return public_key, results
|
|
@@ -861,6 +1054,7 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
861
1054
|
}
|
|
862
1055
|
if not config:
|
|
863
1056
|
return {"configured": False, "network": base_network}
|
|
1057
|
+
_ensure_templates_refreshed_on_startup(config)
|
|
864
1058
|
return {
|
|
865
1059
|
"configured": True,
|
|
866
1060
|
"host": config.get("host"),
|
|
@@ -895,17 +1089,13 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
|
|
|
895
1089
|
network = _ensure_bridge()
|
|
896
1090
|
# Wait for network convergence before validating connectivity
|
|
897
1091
|
time.sleep(2)
|
|
898
|
-
if _verify_connectivity():
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
logger.warning("Bridge setup skipped: %s", exc)
|
|
906
|
-
except Exception as exc: # pragma: no cover - best effort
|
|
907
|
-
network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
|
|
908
|
-
logger.warning("Bridge setup failed: %s", exc)
|
|
1092
|
+
if not _verify_connectivity():
|
|
1093
|
+
raise RuntimeError("Connectivity check failed; bridge reverted")
|
|
1094
|
+
network["health"] = "healthy"
|
|
1095
|
+
except Exception as exc:
|
|
1096
|
+
logger.warning("Bridge setup failed; reverting previous changes: %s", exc)
|
|
1097
|
+
_revert_bridge()
|
|
1098
|
+
raise
|
|
909
1099
|
config = {
|
|
910
1100
|
"host": DEFAULT_HOST,
|
|
911
1101
|
"node": node,
|
|
@@ -968,8 +1158,8 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
|
|
|
968
1158
|
rootfs=rootfs,
|
|
969
1159
|
memory=int(payload["ram_mib"]),
|
|
970
1160
|
swap=int(payload.get("swap_mb", 0)),
|
|
971
|
-
cores=int(payload.get("
|
|
972
|
-
|
|
1161
|
+
cores=max(int(payload.get("cores", 1)), 1),
|
|
1162
|
+
cpulimit=float(payload.get("cpulimit", payload.get("cpus", 1))),
|
|
973
1163
|
net0=payload["net0"],
|
|
974
1164
|
unprivileged=int(payload.get("unprivileged", 1)),
|
|
975
1165
|
description=payload.get("description", MANAGED_MARKER),
|
|
@@ -992,8 +1182,20 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
992
1182
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
993
1183
|
logger.info("create_proxmox_container command received")
|
|
994
1184
|
request_id = message.get("request_id")
|
|
1185
|
+
raw_device_id = message.get("device_id")
|
|
1186
|
+
device_id = str(raw_device_id or "").strip()
|
|
1187
|
+
if not device_id:
|
|
1188
|
+
raise ValueError("device_id is required to create a container")
|
|
1189
|
+
device_public_key = (message.get("device_public_key") or "").strip()
|
|
1190
|
+
device_private_key = (message.get("device_private_key") or "").strip()
|
|
1191
|
+
has_device_keypair = bool(device_public_key and device_private_key)
|
|
995
1192
|
bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
|
|
996
|
-
bootstrap_steps = _build_bootstrap_steps(
|
|
1193
|
+
bootstrap_steps = _build_bootstrap_steps(
|
|
1194
|
+
bootstrap_user,
|
|
1195
|
+
bootstrap_password,
|
|
1196
|
+
bootstrap_ssh_key,
|
|
1197
|
+
include_portacode_connect=not has_device_keypair,
|
|
1198
|
+
)
|
|
997
1199
|
total_steps = 3 + len(bootstrap_steps) + 2
|
|
998
1200
|
current_step_index = 1
|
|
999
1201
|
|
|
@@ -1015,6 +1217,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1015
1217
|
message=start_message,
|
|
1016
1218
|
phase="lifecycle",
|
|
1017
1219
|
request_id=request_id,
|
|
1220
|
+
on_behalf_of_device=device_id,
|
|
1018
1221
|
)
|
|
1019
1222
|
try:
|
|
1020
1223
|
result = action()
|
|
@@ -1030,6 +1233,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1030
1233
|
phase="lifecycle",
|
|
1031
1234
|
request_id=request_id,
|
|
1032
1235
|
details={"error": str(exc)},
|
|
1236
|
+
on_behalf_of_device=device_id,
|
|
1033
1237
|
)
|
|
1034
1238
|
raise
|
|
1035
1239
|
_emit_progress_event(
|
|
@@ -1042,6 +1246,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1042
1246
|
message=success_message,
|
|
1043
1247
|
phase="lifecycle",
|
|
1044
1248
|
request_id=request_id,
|
|
1249
|
+
on_behalf_of_device=device_id,
|
|
1045
1250
|
)
|
|
1046
1251
|
current_step_index += 1
|
|
1047
1252
|
return result
|
|
@@ -1068,7 +1273,8 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1068
1273
|
proxmox = _connect_proxmox(config)
|
|
1069
1274
|
node = config.get("node") or DEFAULT_NODE_NAME
|
|
1070
1275
|
payload = _build_container_payload(message, config)
|
|
1071
|
-
payload["
|
|
1276
|
+
payload["cpulimit"] = float(payload["cpus"])
|
|
1277
|
+
payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
|
|
1072
1278
|
payload["memory"] = int(payload["ram_mib"])
|
|
1073
1279
|
payload["node"] = node
|
|
1074
1280
|
logger.debug(
|
|
@@ -1083,6 +1289,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1083
1289
|
payload["vmid"] = vmid
|
|
1084
1290
|
payload["created_at"] = datetime.utcnow().isoformat() + "Z"
|
|
1085
1291
|
payload["status"] = "creating"
|
|
1292
|
+
payload["device_id"] = device_id
|
|
1086
1293
|
_write_container_record(vmid, payload)
|
|
1087
1294
|
return proxmox, node, vmid, payload
|
|
1088
1295
|
|
|
@@ -1143,6 +1350,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1143
1350
|
phase="bootstrap",
|
|
1144
1351
|
request_id=request_id,
|
|
1145
1352
|
details=details or None,
|
|
1353
|
+
on_behalf_of_device=device_id,
|
|
1146
1354
|
)
|
|
1147
1355
|
|
|
1148
1356
|
public_key, steps = _bootstrap_portacode(
|
|
@@ -1154,9 +1362,107 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1154
1362
|
progress_callback=_bootstrap_progress_callback,
|
|
1155
1363
|
start_index=current_step_index,
|
|
1156
1364
|
total_steps=total_steps,
|
|
1365
|
+
default_public_key=device_public_key if has_device_keypair else None,
|
|
1157
1366
|
)
|
|
1158
1367
|
current_step_index += len(bootstrap_steps)
|
|
1159
1368
|
|
|
1369
|
+
service_installed = False
|
|
1370
|
+
if has_device_keypair:
|
|
1371
|
+
logger.info(
|
|
1372
|
+
"deploying dashboard-provided Portacode keypair (device_id=%s) into container %s",
|
|
1373
|
+
device_id,
|
|
1374
|
+
vmid,
|
|
1375
|
+
)
|
|
1376
|
+
_deploy_device_keypair(
|
|
1377
|
+
vmid,
|
|
1378
|
+
payload["username"],
|
|
1379
|
+
device_private_key,
|
|
1380
|
+
device_public_key,
|
|
1381
|
+
)
|
|
1382
|
+
service_installed = True
|
|
1383
|
+
service_start_index = current_step_index
|
|
1384
|
+
|
|
1385
|
+
auth_step_name = "setup_device_authentication"
|
|
1386
|
+
auth_label = "Setting up device authentication"
|
|
1387
|
+
_emit_progress_event(
|
|
1388
|
+
self,
|
|
1389
|
+
step_index=service_start_index,
|
|
1390
|
+
total_steps=total_steps,
|
|
1391
|
+
step_name=auth_step_name,
|
|
1392
|
+
step_label=auth_label,
|
|
1393
|
+
status="in_progress",
|
|
1394
|
+
message="Notifying the server of the new device…",
|
|
1395
|
+
phase="service",
|
|
1396
|
+
request_id=request_id,
|
|
1397
|
+
on_behalf_of_device=device_id,
|
|
1398
|
+
)
|
|
1399
|
+
_emit_progress_event(
|
|
1400
|
+
self,
|
|
1401
|
+
step_index=service_start_index,
|
|
1402
|
+
total_steps=total_steps,
|
|
1403
|
+
step_name=auth_step_name,
|
|
1404
|
+
step_label=auth_label,
|
|
1405
|
+
status="completed",
|
|
1406
|
+
message="Authentication metadata recorded.",
|
|
1407
|
+
phase="service",
|
|
1408
|
+
request_id=request_id,
|
|
1409
|
+
on_behalf_of_device=device_id,
|
|
1410
|
+
)
|
|
1411
|
+
|
|
1412
|
+
install_step = service_start_index + 1
|
|
1413
|
+
install_label = "Launching Portacode service"
|
|
1414
|
+
_emit_progress_event(
|
|
1415
|
+
self,
|
|
1416
|
+
step_index=install_step,
|
|
1417
|
+
total_steps=total_steps,
|
|
1418
|
+
step_name="launch_portacode_service",
|
|
1419
|
+
step_label=install_label,
|
|
1420
|
+
status="in_progress",
|
|
1421
|
+
message="Running sudo portacode service install…",
|
|
1422
|
+
phase="service",
|
|
1423
|
+
request_id=request_id,
|
|
1424
|
+
on_behalf_of_device=device_id,
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
cmd = f"su - {payload['username']} -c 'sudo -S portacode service install'"
|
|
1428
|
+
res = _run_pct(vmid, cmd, input_text=payload["password"] + "\n")
|
|
1429
|
+
|
|
1430
|
+
if res["returncode"] != 0:
|
|
1431
|
+
_emit_progress_event(
|
|
1432
|
+
self,
|
|
1433
|
+
step_index=install_step,
|
|
1434
|
+
total_steps=total_steps,
|
|
1435
|
+
step_name="launch_portacode_service",
|
|
1436
|
+
step_label=install_label,
|
|
1437
|
+
status="failed",
|
|
1438
|
+
message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
|
|
1439
|
+
phase="service",
|
|
1440
|
+
request_id=request_id,
|
|
1441
|
+
details={
|
|
1442
|
+
"stderr": res.get("stderr"),
|
|
1443
|
+
"stdout": res.get("stdout"),
|
|
1444
|
+
},
|
|
1445
|
+
on_behalf_of_device=device_id,
|
|
1446
|
+
)
|
|
1447
|
+
raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
|
|
1448
|
+
|
|
1449
|
+
_emit_progress_event(
|
|
1450
|
+
self,
|
|
1451
|
+
step_index=install_step,
|
|
1452
|
+
total_steps=total_steps,
|
|
1453
|
+
step_name="launch_portacode_service",
|
|
1454
|
+
step_label=install_label,
|
|
1455
|
+
status="completed",
|
|
1456
|
+
message="Portacode service install finished.",
|
|
1457
|
+
phase="service",
|
|
1458
|
+
request_id=request_id,
|
|
1459
|
+
on_behalf_of_device=device_id,
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
|
|
1463
|
+
|
|
1464
|
+
current_step_index += 2
|
|
1465
|
+
|
|
1160
1466
|
return {
|
|
1161
1467
|
"event": "proxmox_container_created",
|
|
1162
1468
|
"success": True,
|
|
@@ -1173,6 +1479,9 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1173
1479
|
"cpus": payload["cpus"],
|
|
1174
1480
|
},
|
|
1175
1481
|
"setup_steps": steps,
|
|
1482
|
+
"device_id": device_id,
|
|
1483
|
+
"on_behalf_of_device": device_id,
|
|
1484
|
+
"service_installed": service_installed,
|
|
1176
1485
|
}
|
|
1177
1486
|
|
|
1178
1487
|
|
|
@@ -1197,6 +1506,9 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1197
1506
|
password = record.get("password")
|
|
1198
1507
|
if not user or not password:
|
|
1199
1508
|
raise RuntimeError("Container credentials unavailable")
|
|
1509
|
+
on_behalf_of_device = record.get("device_id")
|
|
1510
|
+
if on_behalf_of_device:
|
|
1511
|
+
on_behalf_of_device = str(on_behalf_of_device)
|
|
1200
1512
|
|
|
1201
1513
|
start_index = int(message.get("step_index", 1))
|
|
1202
1514
|
total_steps = int(message.get("total_steps", start_index + 2))
|
|
@@ -1214,6 +1526,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1214
1526
|
message="Notifying the server of the new device…",
|
|
1215
1527
|
phase="service",
|
|
1216
1528
|
request_id=request_id,
|
|
1529
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1217
1530
|
)
|
|
1218
1531
|
_emit_progress_event(
|
|
1219
1532
|
self,
|
|
@@ -1225,6 +1538,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1225
1538
|
message="Authentication metadata recorded.",
|
|
1226
1539
|
phase="service",
|
|
1227
1540
|
request_id=request_id,
|
|
1541
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1228
1542
|
)
|
|
1229
1543
|
|
|
1230
1544
|
install_step = start_index + 1
|
|
@@ -1239,6 +1553,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1239
1553
|
message="Running sudo portacode service install…",
|
|
1240
1554
|
phase="service",
|
|
1241
1555
|
request_id=request_id,
|
|
1556
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1242
1557
|
)
|
|
1243
1558
|
|
|
1244
1559
|
cmd = f"su - {user} -c 'sudo -S portacode service install'"
|
|
@@ -1259,6 +1574,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1259
1574
|
"stderr": res.get("stderr"),
|
|
1260
1575
|
"stdout": res.get("stdout"),
|
|
1261
1576
|
},
|
|
1577
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1262
1578
|
)
|
|
1263
1579
|
raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
|
|
1264
1580
|
|
|
@@ -1272,6 +1588,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1272
1588
|
message="Portacode service install finished.",
|
|
1273
1589
|
phase="service",
|
|
1274
1590
|
request_id=request_id,
|
|
1591
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1275
1592
|
)
|
|
1276
1593
|
|
|
1277
1594
|
return {
|
|
@@ -10,8 +10,9 @@ import platform
|
|
|
10
10
|
import shutil
|
|
11
11
|
import subprocess
|
|
12
12
|
import threading
|
|
13
|
+
import time
|
|
13
14
|
from pathlib import Path
|
|
14
|
-
from typing import Any, Dict
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
15
16
|
|
|
16
17
|
from portacode import __version__
|
|
17
18
|
import psutil
|
|
@@ -31,11 +32,26 @@ _cpu_percent = 0.0
|
|
|
31
32
|
_cpu_thread = None
|
|
32
33
|
_cpu_lock = threading.Lock()
|
|
33
34
|
|
|
35
|
+
# Cgroup v2 tracking
|
|
36
|
+
_CGROUP_ROOT = Path("/sys/fs/cgroup")
|
|
37
|
+
_cgroup_path: Optional[Path] = None
|
|
38
|
+
_cgroup_v2_supported: Optional[bool] = None
|
|
39
|
+
_CGROUP_CPU_STAT = "cpu.stat"
|
|
40
|
+
_CGROUP_CPU_MAX = "cpu.max"
|
|
41
|
+
_last_cgroup_usage: Optional[int] = None
|
|
42
|
+
_last_cgroup_time: Optional[float] = None
|
|
43
|
+
|
|
34
44
|
def _cpu_monitor():
|
|
35
45
|
"""Background thread to update CPU usage every 5 seconds."""
|
|
36
46
|
global _cpu_percent
|
|
37
47
|
while True:
|
|
38
|
-
|
|
48
|
+
percent = _get_cgroup_cpu_percent()
|
|
49
|
+
if percent is None:
|
|
50
|
+
# Fall back to psutil when cgroup stats are not available yet.
|
|
51
|
+
percent = psutil.cpu_percent(interval=5.0)
|
|
52
|
+
else:
|
|
53
|
+
time.sleep(5.0)
|
|
54
|
+
_cpu_percent = percent
|
|
39
55
|
|
|
40
56
|
def _ensure_cpu_thread():
|
|
41
57
|
"""Ensure CPU monitoring thread is running (singleton)."""
|
|
@@ -130,6 +146,119 @@ def _get_playwright_info() -> Dict[str, Any]:
|
|
|
130
146
|
return result
|
|
131
147
|
|
|
132
148
|
|
|
149
|
+
def _resolve_cgroup_path() -> Path:
|
|
150
|
+
global _cgroup_path
|
|
151
|
+
if _cgroup_path is not None and _cgroup_path.exists():
|
|
152
|
+
return _cgroup_path
|
|
153
|
+
path = _CGROUP_ROOT
|
|
154
|
+
cgroup_file = Path("/proc/self/cgroup")
|
|
155
|
+
if cgroup_file.exists():
|
|
156
|
+
try:
|
|
157
|
+
contents = cgroup_file.read_text()
|
|
158
|
+
except OSError:
|
|
159
|
+
pass
|
|
160
|
+
else:
|
|
161
|
+
for line in contents.splitlines():
|
|
162
|
+
line = line.strip()
|
|
163
|
+
if not line:
|
|
164
|
+
continue
|
|
165
|
+
parts = line.split(":", 2)
|
|
166
|
+
if len(parts) < 3:
|
|
167
|
+
continue
|
|
168
|
+
rel_path = parts[-1].lstrip("/")
|
|
169
|
+
candidate = _CGROUP_ROOT / rel_path
|
|
170
|
+
# Fallback to root path if the relative path is empty
|
|
171
|
+
candidate = candidate if rel_path else _CGROUP_ROOT
|
|
172
|
+
if candidate.exists():
|
|
173
|
+
path = candidate
|
|
174
|
+
break
|
|
175
|
+
_cgroup_path = path
|
|
176
|
+
return _cgroup_path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _cgroup_file(name: str) -> Path:
|
|
180
|
+
return _resolve_cgroup_path() / name
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _detect_cgroup_v2() -> bool:
|
|
184
|
+
global _cgroup_v2_supported
|
|
185
|
+
if _cgroup_v2_supported is not None:
|
|
186
|
+
return _cgroup_v2_supported
|
|
187
|
+
controllers = _cgroup_file("cgroup.controllers")
|
|
188
|
+
cpu_stat = _cgroup_file(_CGROUP_CPU_STAT)
|
|
189
|
+
_cgroup_v2_supported = controllers.exists() and cpu_stat.exists()
|
|
190
|
+
return _cgroup_v2_supported
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _read_cgroup_cpu_usage() -> Optional[int]:
|
|
194
|
+
path = _cgroup_file(_CGROUP_CPU_STAT)
|
|
195
|
+
try:
|
|
196
|
+
data = path.read_text()
|
|
197
|
+
except (OSError, UnicodeDecodeError):
|
|
198
|
+
return None
|
|
199
|
+
for line in data.splitlines():
|
|
200
|
+
parts = line.strip().split()
|
|
201
|
+
if len(parts) >= 2 and parts[0] == "usage_usec":
|
|
202
|
+
try:
|
|
203
|
+
return int(parts[1])
|
|
204
|
+
except ValueError:
|
|
205
|
+
return None
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_cgroup_cpu_limit() -> Optional[float]:
|
|
210
|
+
"""Return the allowed CPU (cores) for this cgroup, if limited."""
|
|
211
|
+
path = _cgroup_file(_CGROUP_CPU_MAX)
|
|
212
|
+
if not path.exists():
|
|
213
|
+
return None
|
|
214
|
+
try:
|
|
215
|
+
data = path.read_text().strip()
|
|
216
|
+
except (OSError, UnicodeDecodeError):
|
|
217
|
+
return None
|
|
218
|
+
parts = data.split()
|
|
219
|
+
if len(parts) < 2:
|
|
220
|
+
return None
|
|
221
|
+
quota, period = parts[0], parts[1]
|
|
222
|
+
if quota == "max":
|
|
223
|
+
return None
|
|
224
|
+
try:
|
|
225
|
+
quota_value = int(quota)
|
|
226
|
+
period_value = int(period)
|
|
227
|
+
except ValueError:
|
|
228
|
+
return None
|
|
229
|
+
if period_value <= 0:
|
|
230
|
+
return None
|
|
231
|
+
return quota_value / period_value
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _get_cgroup_cpu_percent() -> Optional[float]:
|
|
235
|
+
if not _detect_cgroup_v2():
|
|
236
|
+
return None
|
|
237
|
+
usage = _read_cgroup_cpu_usage()
|
|
238
|
+
if usage is None:
|
|
239
|
+
return None
|
|
240
|
+
now = time.monotonic()
|
|
241
|
+
global _last_cgroup_usage, _last_cgroup_time
|
|
242
|
+
prev_usage = _last_cgroup_usage
|
|
243
|
+
prev_time = _last_cgroup_time
|
|
244
|
+
_last_cgroup_usage = usage
|
|
245
|
+
_last_cgroup_time = now
|
|
246
|
+
if prev_usage is None or prev_time is None:
|
|
247
|
+
return None
|
|
248
|
+
delta_usage = usage - prev_usage
|
|
249
|
+
delta_time = now - prev_time
|
|
250
|
+
if delta_time <= 0 or delta_usage < 0:
|
|
251
|
+
return None
|
|
252
|
+
cpu_ratio = (delta_usage / 1_000_000) / delta_time
|
|
253
|
+
limit_cpus = _read_cgroup_cpu_limit()
|
|
254
|
+
if limit_cpus and limit_cpus > 0:
|
|
255
|
+
percent = (cpu_ratio / limit_cpus) * 100.0
|
|
256
|
+
else:
|
|
257
|
+
cpu_count = psutil.cpu_count(logical=True) or 1
|
|
258
|
+
percent = (cpu_ratio / cpu_count) * 100.0
|
|
259
|
+
return max(0.0, min(percent, 100.0))
|
|
260
|
+
|
|
261
|
+
|
|
133
262
|
def _run_probe_command(cmd: list[str]) -> str | None:
|
|
134
263
|
try:
|
|
135
264
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=3)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from unittest import TestCase
|
|
2
|
+
|
|
3
|
+
from portacode.connection.handlers.proxmox_infra import _build_bootstrap_steps
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProxmoxInfraHandlerTests(TestCase):
|
|
7
|
+
def test_build_bootstrap_steps_includes_portacode_connect_by_default(self):
|
|
8
|
+
steps = _build_bootstrap_steps("svcuser", "pass", "", include_portacode_connect=True)
|
|
9
|
+
self.assertTrue(any(step.get("name") == "portacode_connect" for step in steps))
|
|
10
|
+
|
|
11
|
+
def test_build_bootstrap_steps_skips_portacode_connect_when_requested(self):
|
|
12
|
+
steps = _build_bootstrap_steps("svcuser", "pass", "", include_portacode_connect=False)
|
|
13
|
+
self.assertFalse(any(step.get("name") == "portacode_connect" for step in steps))
|
portacode/connection/terminal.py
CHANGED
|
@@ -56,6 +56,9 @@ from .handlers import (
|
|
|
56
56
|
CreateProxmoxContainerHandler,
|
|
57
57
|
RevertProxmoxInfraHandler,
|
|
58
58
|
StartPortacodeServiceHandler,
|
|
59
|
+
StartProxmoxContainerHandler,
|
|
60
|
+
StopProxmoxContainerHandler,
|
|
61
|
+
RemoveProxmoxContainerHandler,
|
|
59
62
|
)
|
|
60
63
|
from .handlers.project_aware_file_handlers import (
|
|
61
64
|
ProjectAwareFileWriteHandler,
|
|
@@ -480,6 +483,9 @@ class TerminalManager:
|
|
|
480
483
|
self._command_registry.register(ConfigureProxmoxInfraHandler)
|
|
481
484
|
self._command_registry.register(CreateProxmoxContainerHandler)
|
|
482
485
|
self._command_registry.register(StartPortacodeServiceHandler)
|
|
486
|
+
self._command_registry.register(StartProxmoxContainerHandler)
|
|
487
|
+
self._command_registry.register(StopProxmoxContainerHandler)
|
|
488
|
+
self._command_registry.register(RemoveProxmoxContainerHandler)
|
|
483
489
|
self._command_registry.register(RevertProxmoxInfraHandler)
|
|
484
490
|
self._command_registry.register(UpdatePortacodeHandler)
|
|
485
491
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
|
|
2
2
|
portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
|
|
3
3
|
portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
|
|
4
|
-
portacode/_version.py,sha256=
|
|
4
|
+
portacode/_version.py,sha256=OsU3LmSVwSb16YuRZZoOGhx4fmAppA2W-vOQo60j6hQ,721
|
|
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
|
|
@@ -12,9 +12,9 @@ portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJ
|
|
|
12
12
|
portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
|
|
13
13
|
portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
|
|
14
14
|
portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
|
|
15
|
-
portacode/connection/terminal.py,sha256=
|
|
15
|
+
portacode/connection/terminal.py,sha256=oyLPOVLPlUuN_eRvHPGazB51yi8W8JEF3oOEYxucGTE,45069
|
|
16
16
|
portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
|
|
17
|
-
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=
|
|
17
|
+
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=OtObmoVAXlf0uU1HidTWNmyJYBS1Yl6Rpgyh6TOTjUQ,100590
|
|
18
18
|
portacode/connection/handlers/__init__.py,sha256=WSeBmi65GWFQPYt9M3E10rn0uZ_EPCJzNJOzSf2HZyw,2921
|
|
19
19
|
portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
|
|
20
20
|
portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
|
|
@@ -22,12 +22,13 @@ 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=5DAHhzbegCI0fKdekP8Or6jrGZptmthLdq-RMtOPZDc,63843
|
|
26
26
|
portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
|
|
27
27
|
portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
|
|
28
|
-
portacode/connection/handlers/system_handlers.py,sha256=
|
|
28
|
+
portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
|
|
29
29
|
portacode/connection/handlers/tab_factory.py,sha256=yn93h6GASjD1VpvW1oqpax3EpoT0r7r97zFXxML1wdA,16173
|
|
30
30
|
portacode/connection/handlers/terminal_handlers.py,sha256=HRwHW1GiqG1NtHVEqXHKaYkFfQEzCDDH6YIlHcb4XD8,11866
|
|
31
|
+
portacode/connection/handlers/test_proxmox_infra.py,sha256=d6iBB4pwAqWWdEGRayLxDEexqCElbGZDJlCB4bXba24,682
|
|
31
32
|
portacode/connection/handlers/update_handler.py,sha256=f2K4LmG4sHJZ3LahzzoRtHBULTKkPUNwuyhwuAAg3RA,2054
|
|
32
33
|
portacode/connection/handlers/project_state/README.md,sha256=trdd4ig6ungmwH5SpbSLfyxbL-QgPlGNU-_XrMEiXtw,10114
|
|
33
34
|
portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL8r_wVs5Tlt6B9J7yQH6yQUt7gc,2541
|
|
@@ -64,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
|
|
|
64
65
|
portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
|
|
65
66
|
portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
|
|
66
67
|
portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
|
|
67
|
-
portacode-1.4.
|
|
68
|
+
portacode-1.4.15.dev10.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
|
|
68
69
|
test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
|
|
69
70
|
test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
|
|
70
71
|
test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
|
|
@@ -90,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
|
|
|
90
91
|
testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
|
|
91
92
|
testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
|
|
92
93
|
testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
|
|
93
|
-
portacode-1.4.
|
|
94
|
-
portacode-1.4.
|
|
95
|
-
portacode-1.4.
|
|
96
|
-
portacode-1.4.
|
|
97
|
-
portacode-1.4.
|
|
94
|
+
portacode-1.4.15.dev10.dist-info/METADATA,sha256=FZkeltpJrx27P2fnujDDrYsinIFjnm1VsnnqTSVBk1g,13052
|
|
95
|
+
portacode-1.4.15.dev10.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
96
|
+
portacode-1.4.15.dev10.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
|
|
97
|
+
portacode-1.4.15.dev10.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
|
|
98
|
+
portacode-1.4.15.dev10.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|