portacode 1.4.18.dev0__py3-none-any.whl → 1.4.19__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 +4 -3
- portacode/connection/handlers/base.py +26 -5
- portacode/connection/handlers/proxmox_infra.py +193 -17
- portacode/connection/handlers/registry.py +16 -3
- {portacode-1.4.18.dev0.dist-info → portacode-1.4.19.dist-info}/METADATA +1 -1
- {portacode-1.4.18.dev0.dist-info → portacode-1.4.19.dist-info}/RECORD +11 -11
- {portacode-1.4.18.dev0.dist-info → portacode-1.4.19.dist-info}/WHEEL +0 -0
- {portacode-1.4.18.dev0.dist-info → portacode-1.4.19.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.18.dev0.dist-info → portacode-1.4.19.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.18.dev0.dist-info → portacode-1.4.19.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.19'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 4, 19)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -380,7 +380,7 @@ Starts a previously provisioned, Portacode-managed LXC container. Handled by [`S
|
|
|
380
380
|
|
|
381
381
|
**Payload Fields:**
|
|
382
382
|
|
|
383
|
-
* `ctid` (string,
|
|
383
|
+
* `ctid` (string, optional): Identifier of the container to start. If omitted, the handler resolves the CTID from the managed container records using `child_device_id`.
|
|
384
384
|
* `child_device_id` (string, required): Dashboard `Device.id` of the container that triggered the request; the handler validates the CT belongs to that device before issuing the start.
|
|
385
385
|
|
|
386
386
|
**Responses:**
|
|
@@ -394,7 +394,7 @@ Stops a running Portacode-managed container. Handled by [`StopProxmoxContainerHa
|
|
|
394
394
|
|
|
395
395
|
**Payload Fields:**
|
|
396
396
|
|
|
397
|
-
* `ctid` (string,
|
|
397
|
+
* `ctid` (string, optional): Identifier of the container to stop. If omitted, the handler resolves the CTID from managed container records and, if needed, by scanning managed container descriptions for the matching `device_id` marker.
|
|
398
398
|
* `child_device_id` (string, required): Dashboard `Device.id` that owns the container; the handler rejects the request if the CT is mapped to another device.
|
|
399
399
|
|
|
400
400
|
**Responses:**
|
|
@@ -408,12 +408,13 @@ Deletes a managed container from Proxmox (stopping it first if necessary) and re
|
|
|
408
408
|
|
|
409
409
|
**Payload Fields:**
|
|
410
410
|
|
|
411
|
-
* `ctid` (string,
|
|
411
|
+
* `ctid` (string, optional): Identifier of the container to delete. If omitted, the handler resolves the CTID from managed container records and, if needed, by scanning managed container descriptions for the matching `device_id` marker.
|
|
412
412
|
* `child_device_id` (string, required): Dashboard `Device.id` that should own the container metadata being purged.
|
|
413
413
|
|
|
414
414
|
**Responses:**
|
|
415
415
|
|
|
416
416
|
* Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="remove"` and the refreshed infra snapshot after deletion.
|
|
417
|
+
* If no managed container matches the requested `child_device_id`, emits a `proxmox_container_action` event with `success=false`, `status="not_found"`, and a human-readable message (no error event).
|
|
417
418
|
* Emits an [`error`](#error) event on failure.
|
|
418
419
|
|
|
419
420
|
### `proxmox_container_created`
|
|
@@ -62,16 +62,18 @@ class BaseHandler(ABC):
|
|
|
62
62
|
# Get client session manager from context
|
|
63
63
|
client_session_manager = self.context.get("client_session_manager")
|
|
64
64
|
|
|
65
|
+
bypass_session_gate = bool(payload.get("bypass_session_gate"))
|
|
65
66
|
if client_session_manager and client_session_manager.has_interested_clients():
|
|
66
67
|
# Get target sessions
|
|
67
68
|
target_sessions = client_session_manager.get_target_sessions(project_id)
|
|
68
|
-
if not target_sessions:
|
|
69
|
+
if not target_sessions and not bypass_session_gate:
|
|
69
70
|
logger.debug("handler: No target sessions found, skipping response send")
|
|
70
71
|
return
|
|
71
72
|
|
|
72
73
|
# Add session targeting information
|
|
73
74
|
enhanced_payload = dict(payload)
|
|
74
|
-
|
|
75
|
+
if target_sessions:
|
|
76
|
+
enhanced_payload["client_sessions"] = target_sessions
|
|
75
77
|
|
|
76
78
|
# Add backward compatibility reply_channel (first session if not provided)
|
|
77
79
|
if not reply_channel:
|
|
@@ -89,15 +91,24 @@ class BaseHandler(ABC):
|
|
|
89
91
|
payload["reply_channel"] = reply_channel
|
|
90
92
|
await self.control_channel.send(payload)
|
|
91
93
|
|
|
92
|
-
async def send_error(
|
|
94
|
+
async def send_error(
|
|
95
|
+
self,
|
|
96
|
+
message: str,
|
|
97
|
+
reply_channel: Optional[str] = None,
|
|
98
|
+
project_id: str = None,
|
|
99
|
+
request_id: Optional[str] = None,
|
|
100
|
+
) -> None:
|
|
93
101
|
"""Send an error response with client session awareness.
|
|
94
102
|
|
|
95
103
|
Args:
|
|
96
104
|
message: Error message
|
|
97
105
|
reply_channel: Optional reply channel for backward compatibility
|
|
98
106
|
project_id: Optional project filter for targeting specific sessions
|
|
107
|
+
request_id: Optional request_id to correlate error with a request
|
|
99
108
|
"""
|
|
100
109
|
payload = {"event": "error", "message": message}
|
|
110
|
+
if request_id:
|
|
111
|
+
payload["request_id"] = request_id
|
|
101
112
|
await self.send_response(payload, reply_channel, project_id)
|
|
102
113
|
|
|
103
114
|
|
|
@@ -163,7 +174,12 @@ class AsyncHandler(BaseHandler):
|
|
|
163
174
|
logger.exception("handler: Error in async handler %s: %s", self.command_name, exc)
|
|
164
175
|
# Extract project_id from original message for error targeting
|
|
165
176
|
project_id = message.get("project_id")
|
|
166
|
-
await self.send_error(
|
|
177
|
+
await self.send_error(
|
|
178
|
+
str(exc),
|
|
179
|
+
reply_channel,
|
|
180
|
+
project_id,
|
|
181
|
+
request_id=message.get("request_id"),
|
|
182
|
+
)
|
|
167
183
|
|
|
168
184
|
|
|
169
185
|
class SyncHandler(BaseHandler):
|
|
@@ -216,4 +232,9 @@ class SyncHandler(BaseHandler):
|
|
|
216
232
|
logger.exception("Error in sync handler %s: %s", self.command_name, exc)
|
|
217
233
|
# Extract project_id from original message for error targeting
|
|
218
234
|
project_id = message.get("project_id")
|
|
219
|
-
await self.send_error(
|
|
235
|
+
await self.send_error(
|
|
236
|
+
str(exc),
|
|
237
|
+
reply_channel,
|
|
238
|
+
project_id,
|
|
239
|
+
request_id=message.get("request_id"),
|
|
240
|
+
)
|
|
@@ -17,6 +17,7 @@ import sys
|
|
|
17
17
|
import tempfile
|
|
18
18
|
import time
|
|
19
19
|
import threading
|
|
20
|
+
import random
|
|
20
21
|
from datetime import datetime, timezone
|
|
21
22
|
from pathlib import Path
|
|
22
23
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
@@ -33,6 +34,44 @@ REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
|
33
34
|
NET_SETUP_SCRIPT = REPO_ROOT / "proxmox_management" / "net_setup.py"
|
|
34
35
|
CONTAINERS_DIR = CONFIG_DIR / "containers"
|
|
35
36
|
MANAGED_MARKER = "portacode-managed:true"
|
|
37
|
+
DEVICE_ID_MARKER = "device_id="
|
|
38
|
+
PROVISIONING_ID_MARKER = "provisioning_id="
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _sanitize_description_value(value: Any) -> str:
|
|
42
|
+
return str(value).strip().replace(";", "_")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_managed_description(
|
|
46
|
+
base: Optional[str],
|
|
47
|
+
*,
|
|
48
|
+
device_id: Optional[str] = None,
|
|
49
|
+
provisioning_id: Optional[str] = None,
|
|
50
|
+
) -> str:
|
|
51
|
+
parts = [part for part in (base or "").split(";") if part]
|
|
52
|
+
if MANAGED_MARKER not in parts:
|
|
53
|
+
parts.insert(0, MANAGED_MARKER)
|
|
54
|
+
if device_id:
|
|
55
|
+
device_id_value = _sanitize_description_value(device_id)
|
|
56
|
+
if not any(part.startswith(DEVICE_ID_MARKER) for part in parts):
|
|
57
|
+
parts.append(f"{DEVICE_ID_MARKER}{device_id_value}")
|
|
58
|
+
if provisioning_id:
|
|
59
|
+
provisioning_value = _sanitize_description_value(provisioning_id)
|
|
60
|
+
if not any(part.startswith(PROVISIONING_ID_MARKER) for part in parts):
|
|
61
|
+
parts.append(f"{PROVISIONING_ID_MARKER}{provisioning_value}")
|
|
62
|
+
return ";".join(parts)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_marker_value(description: str, prefix: str) -> Optional[str]:
|
|
66
|
+
for part in description.split(";"):
|
|
67
|
+
part = part.strip()
|
|
68
|
+
if part.startswith(prefix):
|
|
69
|
+
return part[len(prefix) :].strip()
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_device_id_from_description(description: str) -> Optional[str]:
|
|
74
|
+
return _extract_marker_value(description, DEVICE_ID_MARKER)
|
|
36
75
|
|
|
37
76
|
DEFAULT_HOST = "localhost"
|
|
38
77
|
DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
|
|
@@ -116,6 +155,7 @@ def _emit_progress_event(
|
|
|
116
155
|
payload["details"] = details
|
|
117
156
|
if on_behalf_of_device:
|
|
118
157
|
payload["on_behalf_of_device"] = str(on_behalf_of_device)
|
|
158
|
+
payload["bypass_session_gate"] = True
|
|
119
159
|
|
|
120
160
|
future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
|
|
121
161
|
future.add_done_callback(
|
|
@@ -1179,7 +1219,7 @@ def _build_bootstrap_steps(
|
|
|
1179
1219
|
steps.append(
|
|
1180
1220
|
{
|
|
1181
1221
|
"name": "add_ssh_key",
|
|
1182
|
-
"cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}
|
|
1222
|
+
"cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:$(id -gn {shlex.quote(user)}) /home/{user}/.ssh",
|
|
1183
1223
|
"retries": 0,
|
|
1184
1224
|
}
|
|
1185
1225
|
)
|
|
@@ -1256,12 +1296,21 @@ def _validate_positive_number(value: Any, default: float) -> float:
|
|
|
1256
1296
|
|
|
1257
1297
|
|
|
1258
1298
|
def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
|
|
1299
|
+
from proxmoxer.core import ResourceException
|
|
1259
1300
|
start = time.time()
|
|
1301
|
+
poll = 2.0
|
|
1260
1302
|
while True:
|
|
1261
|
-
|
|
1303
|
+
try:
|
|
1304
|
+
status = proxmox.nodes(node).tasks(upid).status.get()
|
|
1305
|
+
except ResourceException as e:
|
|
1306
|
+
if str(e).startswith("599 "):
|
|
1307
|
+
# transient proxy glitch; task may still be running
|
|
1308
|
+
time.sleep(poll + random.random() * 0.5) # small jitter
|
|
1309
|
+
continue
|
|
1310
|
+
raise
|
|
1262
1311
|
if status.get("status") == "stopped":
|
|
1263
1312
|
return status, time.time() - start
|
|
1264
|
-
time.sleep(
|
|
1313
|
+
time.sleep(poll)
|
|
1265
1314
|
|
|
1266
1315
|
|
|
1267
1316
|
def _list_running_managed(proxmox: Any, node: str) -> List[Tuple[str, Dict[str, Any]]]:
|
|
@@ -1399,18 +1448,97 @@ def _parse_ctid(message: Dict[str, Any]) -> int:
|
|
|
1399
1448
|
raise ValueError("ctid is required")
|
|
1400
1449
|
|
|
1401
1450
|
|
|
1451
|
+
class _DeviceLookupError(ValueError):
|
|
1452
|
+
pass
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _resolve_vmid_for_device(device_id: str) -> int:
|
|
1456
|
+
_initialize_managed_containers_state()
|
|
1457
|
+
records = list(_MANAGED_CONTAINERS_STATE.get("records", {}).values())
|
|
1458
|
+
for record in records:
|
|
1459
|
+
record_device_id = record.get("device_id")
|
|
1460
|
+
if record_device_id is None:
|
|
1461
|
+
continue
|
|
1462
|
+
if str(record_device_id) != str(device_id):
|
|
1463
|
+
continue
|
|
1464
|
+
vmid = record.get("vmid")
|
|
1465
|
+
if vmid is None:
|
|
1466
|
+
continue
|
|
1467
|
+
try:
|
|
1468
|
+
return int(str(vmid).strip())
|
|
1469
|
+
except ValueError:
|
|
1470
|
+
raise ValueError("ctid must be an integer") from None
|
|
1471
|
+
raise _DeviceLookupError(
|
|
1472
|
+
f"No managed container record found for device_id {device_id!r}."
|
|
1473
|
+
)
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def _resolve_vmid_for_device_in_proxmox(
|
|
1477
|
+
proxmox: Any, node: str, device_id: str
|
|
1478
|
+
) -> int:
|
|
1479
|
+
device_id_value = str(device_id).strip()
|
|
1480
|
+
matches: List[int] = []
|
|
1481
|
+
try:
|
|
1482
|
+
containers = proxmox.nodes(node).lxc.get()
|
|
1483
|
+
except Exception as exc: # pragma: no cover - proxmox failure
|
|
1484
|
+
raise _DeviceLookupError(
|
|
1485
|
+
f"Unable to query Proxmox containers for device_id {device_id_value!r}: {exc}"
|
|
1486
|
+
) from exc
|
|
1487
|
+
for entry in containers or []:
|
|
1488
|
+
vmid = entry.get("vmid")
|
|
1489
|
+
if vmid is None:
|
|
1490
|
+
continue
|
|
1491
|
+
try:
|
|
1492
|
+
cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
|
|
1493
|
+
except Exception:
|
|
1494
|
+
continue
|
|
1495
|
+
description = (cfg or {}).get("description") or ""
|
|
1496
|
+
if MANAGED_MARKER not in description:
|
|
1497
|
+
continue
|
|
1498
|
+
desc_device_id = _parse_device_id_from_description(description)
|
|
1499
|
+
if desc_device_id is None:
|
|
1500
|
+
continue
|
|
1501
|
+
if str(desc_device_id) != device_id_value:
|
|
1502
|
+
continue
|
|
1503
|
+
matches.append(int(str(vmid).strip()))
|
|
1504
|
+
if not matches:
|
|
1505
|
+
raise _DeviceLookupError(
|
|
1506
|
+
f"No managed container found for device_id {device_id_value!r}. It may already be deleted."
|
|
1507
|
+
)
|
|
1508
|
+
if len(matches) > 1:
|
|
1509
|
+
raise _DeviceLookupError(
|
|
1510
|
+
f"Multiple managed containers found for device_id {device_id_value!r}: {matches}."
|
|
1511
|
+
)
|
|
1512
|
+
return matches[0]
|
|
1513
|
+
|
|
1514
|
+
|
|
1402
1515
|
def _ensure_container_managed(
|
|
1403
1516
|
proxmox: Any, node: str, vmid: int, *, device_id: Optional[str] = None
|
|
1404
1517
|
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
1405
|
-
|
|
1518
|
+
try:
|
|
1519
|
+
record = _read_container_record(vmid)
|
|
1520
|
+
except FileNotFoundError:
|
|
1521
|
+
record = {}
|
|
1406
1522
|
ct_cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
|
|
1407
|
-
|
|
1523
|
+
description = (ct_cfg or {}).get("description") or ""
|
|
1524
|
+
if not ct_cfg or MANAGED_MARKER not in description:
|
|
1408
1525
|
raise RuntimeError(f"Container {vmid} is not managed by Portacode.")
|
|
1409
1526
|
record_device_id = record.get("device_id")
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
)
|
|
1527
|
+
description_device_id = _parse_device_id_from_description(description)
|
|
1528
|
+
if device_id:
|
|
1529
|
+
device_id_value = str(device_id)
|
|
1530
|
+
if description_device_id and str(description_device_id) != device_id_value:
|
|
1531
|
+
raise RuntimeError(
|
|
1532
|
+
f"Container {vmid} is managed for device {description_device_id!r}, not {device_id_value!r}."
|
|
1533
|
+
)
|
|
1534
|
+
if record_device_id and str(record_device_id) != device_id_value:
|
|
1535
|
+
raise RuntimeError(
|
|
1536
|
+
f"Container {vmid} is managed for device {record_device_id!r}, not {device_id_value!r}."
|
|
1537
|
+
)
|
|
1538
|
+
if not description_device_id and not record_device_id:
|
|
1539
|
+
raise RuntimeError(
|
|
1540
|
+
f"Container {vmid} is missing a device_id marker; refusing to operate without ctid."
|
|
1541
|
+
)
|
|
1414
1542
|
return record, ct_cfg
|
|
1415
1543
|
|
|
1416
1544
|
|
|
@@ -1444,6 +1572,14 @@ def _su_command(user: str, command: str) -> str:
|
|
|
1444
1572
|
return f"su - {user} -s /bin/sh -c {shlex.quote(command)}"
|
|
1445
1573
|
|
|
1446
1574
|
|
|
1575
|
+
def _resolve_user_group(vmid: int, user: str) -> str:
|
|
1576
|
+
res = _run_pct(vmid, f"id -gn {shlex.quote(user)}")
|
|
1577
|
+
group = (res.get("stdout") or "").strip()
|
|
1578
|
+
if group:
|
|
1579
|
+
return f"{user}:{group}"
|
|
1580
|
+
return f"{user}:{user}"
|
|
1581
|
+
|
|
1582
|
+
|
|
1447
1583
|
def _resolve_portacode_cli_path(vmid: int, user: str) -> str:
|
|
1448
1584
|
"""Resolve the full path to the portacode CLI inside the container."""
|
|
1449
1585
|
res = _run_pct(vmid, _su_command(user, "command -v portacode"))
|
|
@@ -1480,12 +1616,13 @@ def _push_bytes_to_container(
|
|
|
1480
1616
|
) -> None:
|
|
1481
1617
|
logger.debug("Preparing to push %d bytes to container vmid=%s path=%s for user=%s", len(data), vmid, path, user)
|
|
1482
1618
|
tmp_path: Optional[str] = None
|
|
1619
|
+
owner = _resolve_user_group(vmid, user)
|
|
1483
1620
|
try:
|
|
1484
1621
|
parent = Path(path).parent
|
|
1485
1622
|
parent_str = parent.as_posix()
|
|
1486
1623
|
if parent_str not in {"", ".", "/"}:
|
|
1487
1624
|
_run_pct_exec_check(vmid, ["mkdir", "-p", parent_str])
|
|
1488
|
-
_run_pct_exec_check(vmid, ["chown", "-R",
|
|
1625
|
+
_run_pct_exec_check(vmid, ["chown", "-R", owner, parent_str])
|
|
1489
1626
|
|
|
1490
1627
|
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
|
1491
1628
|
tmp.write(data)
|
|
@@ -1497,7 +1634,7 @@ def _push_bytes_to_container(
|
|
|
1497
1634
|
if push_res.returncode != 0:
|
|
1498
1635
|
raise RuntimeError(push_res.stderr or push_res.stdout or f"pct push returned {push_res.returncode}")
|
|
1499
1636
|
|
|
1500
|
-
_run_pct_exec_check(vmid, ["chown",
|
|
1637
|
+
_run_pct_exec_check(vmid, ["chown", owner, path])
|
|
1501
1638
|
_run_pct_exec_check(vmid, ["chmod", format(mode, "o"), path])
|
|
1502
1639
|
logger.debug("Successfully pushed %d bytes to vmid=%s path=%s", len(data), vmid, path)
|
|
1503
1640
|
except Exception as exc:
|
|
@@ -1516,7 +1653,8 @@ def _resolve_portacode_key_dir(vmid: int, user: str) -> str:
|
|
|
1516
1653
|
data_home = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
|
|
1517
1654
|
portacode_dir = f"{data_home}/portacode"
|
|
1518
1655
|
_run_pct_exec_check(vmid, ["mkdir", "-p", portacode_dir])
|
|
1519
|
-
|
|
1656
|
+
owner = _resolve_user_group(vmid, user)
|
|
1657
|
+
_run_pct_exec_check(vmid, ["chown", "-R", owner, portacode_dir])
|
|
1520
1658
|
return f"{portacode_dir}/keys"
|
|
1521
1659
|
|
|
1522
1660
|
|
|
@@ -2085,7 +2223,11 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
2085
2223
|
payload, device_id=device_id, request_id=request_id
|
|
2086
2224
|
)
|
|
2087
2225
|
provisioning_id = secrets.token_hex(6)
|
|
2088
|
-
payload["description"] =
|
|
2226
|
+
payload["description"] = _build_managed_description(
|
|
2227
|
+
payload.get("description"),
|
|
2228
|
+
device_id=device_id,
|
|
2229
|
+
provisioning_id=provisioning_id,
|
|
2230
|
+
)
|
|
2089
2231
|
|
|
2090
2232
|
def _provision_background() -> None:
|
|
2091
2233
|
nonlocal current_step_index
|
|
@@ -2321,6 +2463,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
2321
2463
|
"setup_steps": steps,
|
|
2322
2464
|
"device_id": device_id,
|
|
2323
2465
|
"on_behalf_of_device": device_id,
|
|
2466
|
+
"bypass_session_gate": True,
|
|
2324
2467
|
"service_installed": service_installed,
|
|
2325
2468
|
"request_id": request_id,
|
|
2326
2469
|
},
|
|
@@ -2347,7 +2490,6 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
2347
2490
|
"event": "proxmox_container_accepted",
|
|
2348
2491
|
"success": True,
|
|
2349
2492
|
"message": "Provisioning accepted; resources reserved.",
|
|
2350
|
-
"device_id": device_id,
|
|
2351
2493
|
"request_id": request_id,
|
|
2352
2494
|
}
|
|
2353
2495
|
|
|
@@ -2475,13 +2617,19 @@ class StartProxmoxContainerHandler(SyncHandler):
|
|
|
2475
2617
|
return "start_proxmox_container"
|
|
2476
2618
|
|
|
2477
2619
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
2478
|
-
vmid = _parse_ctid(message)
|
|
2479
2620
|
child_device_id = (message.get("child_device_id") or "").strip()
|
|
2480
2621
|
if not child_device_id:
|
|
2481
2622
|
raise ValueError("child_device_id is required for start_proxmox_container")
|
|
2482
2623
|
config = _ensure_infra_configured()
|
|
2483
2624
|
proxmox = _connect_proxmox(config)
|
|
2484
2625
|
node = _get_node_from_config(config)
|
|
2626
|
+
try:
|
|
2627
|
+
vmid = _parse_ctid(message)
|
|
2628
|
+
except ValueError:
|
|
2629
|
+
try:
|
|
2630
|
+
vmid = _resolve_vmid_for_device(child_device_id)
|
|
2631
|
+
except _DeviceLookupError:
|
|
2632
|
+
vmid = _resolve_vmid_for_device_in_proxmox(proxmox, node, child_device_id)
|
|
2485
2633
|
_ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
|
|
2486
2634
|
|
|
2487
2635
|
status, elapsed = _start_container(proxmox, node, vmid)
|
|
@@ -2508,13 +2656,19 @@ class StopProxmoxContainerHandler(SyncHandler):
|
|
|
2508
2656
|
return "stop_proxmox_container"
|
|
2509
2657
|
|
|
2510
2658
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
2511
|
-
vmid = _parse_ctid(message)
|
|
2512
2659
|
child_device_id = (message.get("child_device_id") or "").strip()
|
|
2513
2660
|
if not child_device_id:
|
|
2514
2661
|
raise ValueError("child_device_id is required for stop_proxmox_container")
|
|
2515
2662
|
config = _ensure_infra_configured()
|
|
2516
2663
|
proxmox = _connect_proxmox(config)
|
|
2517
2664
|
node = _get_node_from_config(config)
|
|
2665
|
+
try:
|
|
2666
|
+
vmid = _parse_ctid(message)
|
|
2667
|
+
except ValueError:
|
|
2668
|
+
try:
|
|
2669
|
+
vmid = _resolve_vmid_for_device(child_device_id)
|
|
2670
|
+
except _DeviceLookupError:
|
|
2671
|
+
vmid = _resolve_vmid_for_device_in_proxmox(proxmox, node, child_device_id)
|
|
2518
2672
|
_ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
|
|
2519
2673
|
|
|
2520
2674
|
status, elapsed = _stop_container(proxmox, node, vmid)
|
|
@@ -2547,13 +2701,33 @@ class RemoveProxmoxContainerHandler(SyncHandler):
|
|
|
2547
2701
|
return "remove_proxmox_container"
|
|
2548
2702
|
|
|
2549
2703
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
2550
|
-
vmid = _parse_ctid(message)
|
|
2551
2704
|
child_device_id = (message.get("child_device_id") or "").strip()
|
|
2552
2705
|
if not child_device_id:
|
|
2553
2706
|
raise ValueError("child_device_id is required for remove_proxmox_container")
|
|
2707
|
+
request_id = message.get("request_id")
|
|
2554
2708
|
config = _ensure_infra_configured()
|
|
2555
2709
|
proxmox = _connect_proxmox(config)
|
|
2556
2710
|
node = _get_node_from_config(config)
|
|
2711
|
+
try:
|
|
2712
|
+
vmid = _parse_ctid(message)
|
|
2713
|
+
except ValueError:
|
|
2714
|
+
try:
|
|
2715
|
+
vmid = _resolve_vmid_for_device(child_device_id)
|
|
2716
|
+
except _DeviceLookupError:
|
|
2717
|
+
try:
|
|
2718
|
+
vmid = _resolve_vmid_for_device_in_proxmox(proxmox, node, child_device_id)
|
|
2719
|
+
except _DeviceLookupError as exc:
|
|
2720
|
+
infra = get_infra_snapshot()
|
|
2721
|
+
return {
|
|
2722
|
+
"event": "proxmox_container_action",
|
|
2723
|
+
"action": "remove",
|
|
2724
|
+
"success": False,
|
|
2725
|
+
"message": str(exc),
|
|
2726
|
+
"status": "not_found",
|
|
2727
|
+
"child_device_id": child_device_id,
|
|
2728
|
+
"request_id": request_id,
|
|
2729
|
+
"infra": infra,
|
|
2730
|
+
}
|
|
2557
2731
|
_ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
|
|
2558
2732
|
|
|
2559
2733
|
stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
|
|
@@ -2572,6 +2746,8 @@ class RemoveProxmoxContainerHandler(SyncHandler):
|
|
|
2572
2746
|
"delete_exitstatus": delete_status.get("exitstatus"),
|
|
2573
2747
|
},
|
|
2574
2748
|
"status": "deleted",
|
|
2749
|
+
"child_device_id": child_device_id,
|
|
2750
|
+
"request_id": request_id,
|
|
2575
2751
|
"infra": infra,
|
|
2576
2752
|
}
|
|
2577
2753
|
|
|
@@ -106,12 +106,25 @@ class CommandRegistry:
|
|
|
106
106
|
except Exception as exc:
|
|
107
107
|
logger.exception("registry: Error dispatching command %s: %s", command_name, exc)
|
|
108
108
|
# Send session-aware error response
|
|
109
|
-
await self._send_session_aware_error(
|
|
109
|
+
await self._send_session_aware_error(
|
|
110
|
+
str(exc),
|
|
111
|
+
reply_channel,
|
|
112
|
+
message.get("project_id"),
|
|
113
|
+
request_id=message.get("request_id"),
|
|
114
|
+
)
|
|
110
115
|
return False
|
|
111
116
|
|
|
112
|
-
async def _send_session_aware_error(
|
|
117
|
+
async def _send_session_aware_error(
|
|
118
|
+
self,
|
|
119
|
+
message: str,
|
|
120
|
+
reply_channel: Optional[str] = None,
|
|
121
|
+
project_id: str = None,
|
|
122
|
+
request_id: Optional[str] = None,
|
|
123
|
+
) -> None:
|
|
113
124
|
"""Send an error response with client session awareness."""
|
|
114
125
|
error_payload = {"event": "error", "message": message}
|
|
126
|
+
if request_id:
|
|
127
|
+
error_payload["request_id"] = request_id
|
|
115
128
|
|
|
116
129
|
# Get client session manager from context
|
|
117
130
|
client_session_manager = self.context.get("client_session_manager")
|
|
@@ -151,4 +164,4 @@ class CommandRegistry:
|
|
|
151
164
|
|
|
152
165
|
# Update context for all existing handlers
|
|
153
166
|
for handler in self._handlers.values():
|
|
154
|
-
handler.context = self.context
|
|
167
|
+
handler.context = self.context
|
|
@@ -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=keZQZ9CsYVpzJllmOkOL3XJxArYmisEYXuC-FCqDD4o,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
|
|
@@ -14,16 +14,16 @@ portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5wei
|
|
|
14
14
|
portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
|
|
15
15
|
portacode/connection/terminal.py,sha256=n1Uu92JacV5K6d1Qwx94Tw9OB2Tpke5HqsW2NDn76Ls,49032
|
|
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=_h44VpS1P_7AK-4Yx9aE62G1vptkFhXoHmwvH5PAFvA,104266
|
|
18
18
|
portacode/connection/handlers/__init__.py,sha256=WSeBmi65GWFQPYt9M3E10rn0uZ_EPCJzNJOzSf2HZyw,2921
|
|
19
|
-
portacode/connection/handlers/base.py,sha256=
|
|
19
|
+
portacode/connection/handlers/base.py,sha256=tMsKJmnNhXA3a4YZA55a0WO2e8-4Uh4BsG9lWAr3Ris,10829
|
|
20
20
|
portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
|
|
21
21
|
portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZbQw6sF04M3dM6rUV8Q,24477
|
|
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=
|
|
26
|
-
portacode/connection/handlers/registry.py,sha256=
|
|
25
|
+
portacode/connection/handlers/proxmox_infra.py,sha256=Era8MwTKj6RrNBHWd8uk4lE379DaD2fLU0zJr2061jM,106048
|
|
26
|
+
portacode/connection/handlers/registry.py,sha256=elISoWpIXapMuY28x1hzXV-uErtAklsFzMb8yVgp7NU,6456
|
|
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
|
|
29
29
|
portacode/connection/handlers/tab_factory.py,sha256=yn93h6GASjD1VpvW1oqpax3EpoT0r7r97zFXxML1wdA,16173
|
|
@@ -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.19.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.19.dist-info/METADATA,sha256=5aVx15tKibAZEqBLxn8FzTID82COP9Im2PEX3XYgSaY,13046
|
|
95
|
+
portacode-1.4.19.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
96
|
+
portacode-1.4.19.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
|
|
97
|
+
portacode-1.4.19.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
|
|
98
|
+
portacode-1.4.19.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|