portacode 1.4.14__py3-none-any.whl → 1.4.15__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 +11 -2
- portacode/connection/handlers/project_state/manager.py +25 -3
- portacode/connection/handlers/proxmox_infra.py +341 -50
- portacode/connection/terminal.py +80 -0
- {portacode-1.4.14.dist-info → portacode-1.4.15.dist-info}/METADATA +1 -1
- {portacode-1.4.14.dist-info → portacode-1.4.15.dist-info}/RECORD +11 -11
- {portacode-1.4.14.dist-info → portacode-1.4.15.dist-info}/WHEEL +1 -1
- {portacode-1.4.14.dist-info → portacode-1.4.15.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.14.dist-info → portacode-1.4.15.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.14.dist-info → portacode-1.4.15.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'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 4, 15)
|
|
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,7 +365,7 @@ 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.
|
|
364
|
-
* `device_id` (string,
|
|
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.
|
|
365
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.
|
|
366
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.
|
|
367
371
|
|
|
@@ -421,7 +425,8 @@ Emitted after a successful `create_proxmox_container` action. Contains the new c
|
|
|
421
425
|
* `public_key` (string): Portacode public auth key created inside the new container.
|
|
422
426
|
* `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
|
|
423
427
|
* `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
|
|
424
|
-
* `device_id` (string
|
|
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.
|
|
425
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`.
|
|
426
431
|
|
|
427
432
|
### `proxmox_container_progress`
|
|
@@ -439,6 +444,7 @@ Sent intermittently while `create_proxmox_container` is executing so callers can
|
|
|
439
444
|
* `message` (string): Short description of what is happening or why a failure occurred.
|
|
440
445
|
* `details` (object, optional): Contains `attempt` (if retries were needed) and `error_summary` when a step fails.
|
|
441
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.
|
|
442
448
|
|
|
443
449
|
### `start_portacode_service`
|
|
444
450
|
|
|
@@ -1177,6 +1183,8 @@ Emitted after a successful `create_proxmox_container` action to report the newly
|
|
|
1177
1183
|
* `public_key` (string): Portacode public auth key discovered inside the container.
|
|
1178
1184
|
* `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
|
|
1179
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.
|
|
1180
1188
|
|
|
1181
1189
|
### `proxmox_container_progress`
|
|
1182
1190
|
|
|
@@ -1193,6 +1201,7 @@ Sent continuously while `create_proxmox_container` runs so dashboards can show a
|
|
|
1193
1201
|
* `message` (string): Short human-readable description of the action or failure.
|
|
1194
1202
|
* `details` (object, optional): Contains `attempt` (when retries are used) and `error_summary` on failure.
|
|
1195
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.
|
|
1196
1205
|
|
|
1197
1206
|
### `proxmox_container_action`
|
|
1198
1207
|
|
|
@@ -21,6 +21,24 @@ from .git_manager import GitManager
|
|
|
21
21
|
from .file_system_watcher import FileSystemWatcher
|
|
22
22
|
from ....logging_categories import get_categorized_logger, LogCategory
|
|
23
23
|
|
|
24
|
+
def _deterministic_file_tab_id(file_path: str) -> str:
|
|
25
|
+
try:
|
|
26
|
+
resolved = os.path.abspath(file_path)
|
|
27
|
+
mtime = int(Path(resolved).stat().st_mtime)
|
|
28
|
+
except OSError:
|
|
29
|
+
mtime = "unknown"
|
|
30
|
+
resolved = os.path.abspath(file_path)
|
|
31
|
+
return f"{resolved}:{mtime}"
|
|
32
|
+
|
|
33
|
+
def _deterministic_diff_tab_id(
|
|
34
|
+
file_path: str,
|
|
35
|
+
from_ref: str,
|
|
36
|
+
to_ref: str,
|
|
37
|
+
from_hash: Optional[str],
|
|
38
|
+
to_hash: Optional[str]
|
|
39
|
+
) -> str:
|
|
40
|
+
return f"diff:{os.path.abspath(file_path)}:{from_ref}:{to_ref}:{from_hash or ''}:{to_hash or ''}"
|
|
41
|
+
|
|
24
42
|
logger = get_categorized_logger(__name__)
|
|
25
43
|
|
|
26
44
|
# Global singleton instance
|
|
@@ -657,10 +675,10 @@ class ProjectStateManager:
|
|
|
657
675
|
# Create new file tab using tab factory
|
|
658
676
|
from ..tab_factory import get_tab_factory
|
|
659
677
|
tab_factory = get_tab_factory()
|
|
660
|
-
|
|
678
|
+
|
|
661
679
|
try:
|
|
662
680
|
logger.info(f"About to create tab for file: {file_path}")
|
|
663
|
-
new_tab = await tab_factory.create_file_tab(file_path)
|
|
681
|
+
new_tab = await tab_factory.create_file_tab(file_path, tab_id=_deterministic_file_tab_id(file_path))
|
|
664
682
|
logger.info(f"Tab created successfully, adding to project state")
|
|
665
683
|
project_state.open_tabs[tab_key] = new_tab
|
|
666
684
|
if set_active:
|
|
@@ -842,7 +860,11 @@ class ProjectStateManager:
|
|
|
842
860
|
diff_title = f"{os.path.basename(file_path)} ({' '.join(title_parts)})"
|
|
843
861
|
|
|
844
862
|
diff_tab = await tab_factory.create_diff_tab_with_title(
|
|
845
|
-
file_path,
|
|
863
|
+
file_path,
|
|
864
|
+
original_content,
|
|
865
|
+
modified_content,
|
|
866
|
+
diff_title,
|
|
867
|
+
tab_id=_deterministic_diff_tab_id(file_path, from_ref, to_ref, from_hash, to_hash),
|
|
846
868
|
diff_details=diff_details
|
|
847
869
|
)
|
|
848
870
|
|
|
@@ -5,6 +5,7 @@ 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
|
|
10
11
|
import shlex
|
|
@@ -15,7 +16,7 @@ import sys
|
|
|
15
16
|
import tempfile
|
|
16
17
|
import time
|
|
17
18
|
import threading
|
|
18
|
-
from datetime import datetime
|
|
19
|
+
from datetime import datetime, timezone
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
21
22
|
|
|
@@ -46,6 +47,7 @@ UNIT_DIR = Path("/etc/systemd/system")
|
|
|
46
47
|
_MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
|
|
47
48
|
_MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
|
|
48
49
|
_MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
|
|
50
|
+
TEMPLATES_REFRESH_INTERVAL_S = 300
|
|
49
51
|
|
|
50
52
|
ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
|
|
51
53
|
|
|
@@ -62,6 +64,7 @@ def _emit_progress_event(
|
|
|
62
64
|
phase: str,
|
|
63
65
|
request_id: Optional[str],
|
|
64
66
|
details: Optional[Dict[str, Any]] = None,
|
|
67
|
+
on_behalf_of_device: Optional[str] = None,
|
|
65
68
|
) -> None:
|
|
66
69
|
loop = handler.context.get("event_loop")
|
|
67
70
|
if not loop or loop.is_closed():
|
|
@@ -86,6 +89,8 @@ def _emit_progress_event(
|
|
|
86
89
|
payload["request_id"] = request_id
|
|
87
90
|
if details:
|
|
88
91
|
payload["details"] = details
|
|
92
|
+
if on_behalf_of_device:
|
|
93
|
+
payload["on_behalf_of_device"] = str(on_behalf_of_device)
|
|
89
94
|
|
|
90
95
|
future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
|
|
91
96
|
future.add_done_callback(
|
|
@@ -175,6 +180,64 @@ def _list_templates(client: Any, node: str, storages: Iterable[Dict[str, Any]])
|
|
|
175
180
|
return templates
|
|
176
181
|
|
|
177
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 _current_time_iso() -> str:
|
|
201
|
+
return datetime.now(timezone.utc).isoformat()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_iso_timestamp(value: str) -> Optional[datetime]:
|
|
205
|
+
if not value:
|
|
206
|
+
return None
|
|
207
|
+
text = value
|
|
208
|
+
if text.endswith("Z"):
|
|
209
|
+
text = text[:-1] + "+00:00"
|
|
210
|
+
try:
|
|
211
|
+
return datetime.fromisoformat(text)
|
|
212
|
+
except ValueError:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _templates_need_refresh(config: Dict[str, Any]) -> bool:
|
|
217
|
+
if not config or not config.get("token_value"):
|
|
218
|
+
return False
|
|
219
|
+
last = _parse_iso_timestamp(config.get("templates_last_refreshed") or "")
|
|
220
|
+
if not last:
|
|
221
|
+
return True
|
|
222
|
+
return (datetime.now(timezone.utc) - last).total_seconds() >= TEMPLATES_REFRESH_INTERVAL_S
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
|
|
226
|
+
if not _templates_need_refresh(config):
|
|
227
|
+
return
|
|
228
|
+
try:
|
|
229
|
+
client = _build_proxmox_client_from_config(config)
|
|
230
|
+
node = config.get("node") or _pick_node(client)
|
|
231
|
+
storages = client.nodes(node).storage.get()
|
|
232
|
+
templates = _list_templates(client, node, storages)
|
|
233
|
+
if templates:
|
|
234
|
+
config["templates"] = templates
|
|
235
|
+
config["templates_last_refreshed"] = _current_time_iso()
|
|
236
|
+
_save_config(config)
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
|
|
239
|
+
|
|
240
|
+
|
|
178
241
|
def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
|
|
179
242
|
candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
|
|
180
243
|
if not candidates:
|
|
@@ -433,48 +496,171 @@ def _friendly_step_label(step_name: str) -> str:
|
|
|
433
496
|
return normalized.capitalize()
|
|
434
497
|
|
|
435
498
|
|
|
499
|
+
_NETWORK_WAIT_CMD = (
|
|
500
|
+
"count=0; "
|
|
501
|
+
"while [ \"$count\" -lt 20 ]; do "
|
|
502
|
+
" if command -v ip >/dev/null 2>&1 && ip route get 1.1.1.1 >/dev/null 2>&1; then break; fi; "
|
|
503
|
+
" if [ -f /proc/net/route ] && grep -q '^00000000' /proc/net/route >/dev/null 2>&1; then break; fi; "
|
|
504
|
+
" sleep 1; "
|
|
505
|
+
" count=$((count+1)); "
|
|
506
|
+
"done"
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
_PACKAGE_MANAGER_PROFILES: Dict[str, Dict[str, Any]] = {
|
|
510
|
+
"apt": {
|
|
511
|
+
"update_cmd": "apt-get update -y",
|
|
512
|
+
"update_step_name": "apt_update",
|
|
513
|
+
"install_cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
|
|
514
|
+
"install_step_name": "install_deps",
|
|
515
|
+
"update_retries": 4,
|
|
516
|
+
"install_retries": 5,
|
|
517
|
+
},
|
|
518
|
+
"dnf": {
|
|
519
|
+
"update_cmd": "dnf check-update || true",
|
|
520
|
+
"update_step_name": "dnf_update",
|
|
521
|
+
"install_cmd": "dnf install -y python3 python3-pip sudo",
|
|
522
|
+
"install_step_name": "install_deps",
|
|
523
|
+
"update_retries": 3,
|
|
524
|
+
"install_retries": 5,
|
|
525
|
+
},
|
|
526
|
+
"yum": {
|
|
527
|
+
"update_cmd": "yum makecache",
|
|
528
|
+
"update_step_name": "yum_update",
|
|
529
|
+
"install_cmd": "yum install -y python3 python3-pip sudo",
|
|
530
|
+
"install_step_name": "install_deps",
|
|
531
|
+
"update_retries": 3,
|
|
532
|
+
"install_retries": 5,
|
|
533
|
+
},
|
|
534
|
+
"apk": {
|
|
535
|
+
"update_cmd": "apk update",
|
|
536
|
+
"update_step_name": "apk_update",
|
|
537
|
+
"install_cmd": "apk add --no-cache python3 py3-pip sudo shadow",
|
|
538
|
+
"install_step_name": "install_deps",
|
|
539
|
+
"update_retries": 3,
|
|
540
|
+
"install_retries": 5,
|
|
541
|
+
},
|
|
542
|
+
"pacman": {
|
|
543
|
+
"update_cmd": "pacman -Sy --noconfirm",
|
|
544
|
+
"update_step_name": "pacman_update",
|
|
545
|
+
"install_cmd": "pacman -S --noconfirm python python-pip sudo",
|
|
546
|
+
"install_step_name": "install_deps",
|
|
547
|
+
"update_retries": 3,
|
|
548
|
+
"install_retries": 5,
|
|
549
|
+
},
|
|
550
|
+
"zypper": {
|
|
551
|
+
"update_cmd": "zypper refresh",
|
|
552
|
+
"update_step_name": "zypper_update",
|
|
553
|
+
"install_cmd": "zypper install -y python3 python3-pip sudo",
|
|
554
|
+
"install_step_name": "install_deps",
|
|
555
|
+
"update_retries": 3,
|
|
556
|
+
"install_retries": 5,
|
|
557
|
+
},
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
_UPDATE_RETRY_ON = [
|
|
561
|
+
"Temporary failure resolving",
|
|
562
|
+
"Could not resolve",
|
|
563
|
+
"Failed to fetch",
|
|
564
|
+
]
|
|
565
|
+
|
|
566
|
+
_INSTALL_RETRY_ON = [
|
|
567
|
+
"lock-frontend",
|
|
568
|
+
"Unable to acquire the dpkg frontend lock",
|
|
569
|
+
"Temporary failure resolving",
|
|
570
|
+
"Could not resolve",
|
|
571
|
+
"Failed to fetch",
|
|
572
|
+
]
|
|
573
|
+
|
|
574
|
+
|
|
436
575
|
def _build_bootstrap_steps(
|
|
437
576
|
user: str,
|
|
438
577
|
password: str,
|
|
439
578
|
ssh_key: str,
|
|
440
579
|
include_portacode_connect: bool = True,
|
|
580
|
+
package_manager: str = "apt",
|
|
441
581
|
) -> List[Dict[str, Any]]:
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
"
|
|
451
|
-
"
|
|
452
|
-
|
|
453
|
-
|
|
582
|
+
profile = _PACKAGE_MANAGER_PROFILES.get(package_manager, _PACKAGE_MANAGER_PROFILES["apt"])
|
|
583
|
+
steps: List[Dict[str, Any]] = [
|
|
584
|
+
{"name": "wait_for_network", "cmd": _NETWORK_WAIT_CMD, "retries": 0},
|
|
585
|
+
]
|
|
586
|
+
update_cmd = profile.get("update_cmd")
|
|
587
|
+
if update_cmd:
|
|
588
|
+
steps.append(
|
|
589
|
+
{
|
|
590
|
+
"name": profile.get("update_step_name", "package_update"),
|
|
591
|
+
"cmd": update_cmd,
|
|
592
|
+
"retries": profile.get("update_retries", 3),
|
|
593
|
+
"retry_delay_s": 5,
|
|
594
|
+
"retry_on": _UPDATE_RETRY_ON,
|
|
595
|
+
}
|
|
596
|
+
)
|
|
597
|
+
install_cmd = profile.get("install_cmd")
|
|
598
|
+
if install_cmd:
|
|
599
|
+
steps.append(
|
|
600
|
+
{
|
|
601
|
+
"name": profile.get("install_step_name", "install_deps"),
|
|
602
|
+
"cmd": install_cmd,
|
|
603
|
+
"retries": profile.get("install_retries", 5),
|
|
604
|
+
"retry_delay_s": 5,
|
|
605
|
+
"retry_on": _INSTALL_RETRY_ON,
|
|
606
|
+
}
|
|
607
|
+
)
|
|
608
|
+
steps.extend(
|
|
609
|
+
[
|
|
610
|
+
{
|
|
611
|
+
"name": "user_exists",
|
|
612
|
+
"cmd": (
|
|
613
|
+
f"id -u {user} >/dev/null 2>&1 || "
|
|
614
|
+
f"(if command -v adduser >/dev/null 2>&1 && adduser --disabled-password --help >/dev/null 2>&1; then "
|
|
615
|
+
f" adduser --disabled-password --gecos '' {user}; "
|
|
616
|
+
"else "
|
|
617
|
+
f" useradd -m -s /bin/sh {user}; "
|
|
618
|
+
"fi)"
|
|
619
|
+
),
|
|
620
|
+
"retries": 0,
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
"name": "add_sudo",
|
|
624
|
+
"cmd": (
|
|
625
|
+
f"if command -v usermod >/dev/null 2>&1; then "
|
|
626
|
+
" if ! getent group sudo >/dev/null 2>&1; then "
|
|
627
|
+
" if command -v groupadd >/dev/null 2>&1; then "
|
|
628
|
+
" groupadd sudo >/dev/null 2>&1 || true; "
|
|
629
|
+
" fi; "
|
|
630
|
+
" fi; "
|
|
631
|
+
f" usermod -aG sudo {user}; "
|
|
632
|
+
"else "
|
|
633
|
+
" for grp in wheel sudo; do "
|
|
634
|
+
" if ! getent group \"$grp\" >/dev/null 2>&1 && command -v groupadd >/dev/null 2>&1; then "
|
|
635
|
+
" groupadd \"$grp\" >/dev/null 2>&1 || true; "
|
|
636
|
+
" fi; "
|
|
637
|
+
" addgroup \"$grp\" >/dev/null 2>&1 || true; "
|
|
638
|
+
f" addgroup {user} \"$grp\" >/dev/null 2>&1 || true; "
|
|
639
|
+
" done; "
|
|
640
|
+
"fi"
|
|
641
|
+
),
|
|
642
|
+
"retries": 0,
|
|
643
|
+
},
|
|
454
644
|
{
|
|
455
|
-
"name": "
|
|
456
|
-
"cmd":
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
"Unable to acquire the dpkg frontend lock",
|
|
462
|
-
"Temporary failure resolving",
|
|
463
|
-
"Could not resolve",
|
|
464
|
-
"Failed to fetch",
|
|
465
|
-
],
|
|
645
|
+
"name": "add_sudoers",
|
|
646
|
+
"cmd": (
|
|
647
|
+
f"printf '%s ALL=(ALL) NOPASSWD:ALL\\n' {shlex.quote(user)} >/etc/sudoers.d/portacode && "
|
|
648
|
+
"chmod 0440 /etc/sudoers.d/portacode"
|
|
649
|
+
),
|
|
650
|
+
"retries": 0,
|
|
466
651
|
},
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
]
|
|
652
|
+
]
|
|
653
|
+
)
|
|
470
654
|
if password:
|
|
471
655
|
steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
|
|
472
656
|
if ssh_key:
|
|
473
|
-
steps.append(
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
657
|
+
steps.append(
|
|
658
|
+
{
|
|
659
|
+
"name": "add_ssh_key",
|
|
660
|
+
"cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
|
|
661
|
+
"retries": 0,
|
|
662
|
+
}
|
|
663
|
+
)
|
|
478
664
|
steps.extend(
|
|
479
665
|
[
|
|
480
666
|
{"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
|
|
@@ -486,6 +672,45 @@ def _build_bootstrap_steps(
|
|
|
486
672
|
return steps
|
|
487
673
|
|
|
488
674
|
|
|
675
|
+
def _guess_package_manager_from_template(template: str) -> str:
|
|
676
|
+
normalized = (template or "").lower()
|
|
677
|
+
if "alpine" in normalized:
|
|
678
|
+
return "apk"
|
|
679
|
+
if "archlinux" in normalized:
|
|
680
|
+
return "pacman"
|
|
681
|
+
if "centos-7" in normalized:
|
|
682
|
+
return "yum"
|
|
683
|
+
if any(keyword in normalized for keyword in ("centos-8", "centos-9", "centos-9-stream", "centos-8-stream")):
|
|
684
|
+
return "dnf"
|
|
685
|
+
if any(keyword in normalized for keyword in ("rockylinux", "almalinux", "fedora")):
|
|
686
|
+
return "dnf"
|
|
687
|
+
if "opensuse" in normalized or "suse" in normalized:
|
|
688
|
+
return "zypper"
|
|
689
|
+
if any(keyword in normalized for keyword in ("debian", "ubuntu", "devuan", "turnkeylinux")):
|
|
690
|
+
return "apt"
|
|
691
|
+
if normalized.startswith("system/") and "linux" in normalized:
|
|
692
|
+
return "apt"
|
|
693
|
+
return "apt"
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _detect_package_manager(vmid: int) -> str:
|
|
697
|
+
candidates = [
|
|
698
|
+
("apt", "apt-get"),
|
|
699
|
+
("dnf", "dnf"),
|
|
700
|
+
("yum", "yum"),
|
|
701
|
+
("apk", "apk"),
|
|
702
|
+
("pacman", "pacman"),
|
|
703
|
+
("zypper", "zypper"),
|
|
704
|
+
]
|
|
705
|
+
for name, binary in candidates:
|
|
706
|
+
res = _run_pct(vmid, f"command -v {binary} >/dev/null 2>&1")
|
|
707
|
+
if res.get("returncode") == 0:
|
|
708
|
+
logger.debug("Detected package manager %s inside container %s", name, vmid)
|
|
709
|
+
return name
|
|
710
|
+
logger.warning("Unable to detect package manager inside container %s; defaulting to apt", vmid)
|
|
711
|
+
return "apt"
|
|
712
|
+
|
|
713
|
+
|
|
489
714
|
def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
|
|
490
715
|
for entry in storages:
|
|
491
716
|
if entry.get("storage") == storage_name:
|
|
@@ -673,7 +898,8 @@ def _connect_proxmox(config: Dict[str, Any]) -> Any:
|
|
|
673
898
|
|
|
674
899
|
|
|
675
900
|
def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str, Any]:
|
|
676
|
-
|
|
901
|
+
shell = "/bin/sh"
|
|
902
|
+
full = ["pct", "exec", str(vmid), "--", shell, "-c", cmd]
|
|
677
903
|
start = time.time()
|
|
678
904
|
proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, input=input_text)
|
|
679
905
|
return {
|
|
@@ -685,6 +911,10 @@ def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str,
|
|
|
685
911
|
}
|
|
686
912
|
|
|
687
913
|
|
|
914
|
+
def _su_command(user: str, command: str) -> str:
|
|
915
|
+
return f"su - {user} -s /bin/sh -c {shlex.quote(command)}"
|
|
916
|
+
|
|
917
|
+
|
|
688
918
|
def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
|
|
689
919
|
res = _run_pct(vmid, cmd)
|
|
690
920
|
if res["returncode"] != 0:
|
|
@@ -744,7 +974,7 @@ def _push_bytes_to_container(
|
|
|
744
974
|
|
|
745
975
|
|
|
746
976
|
def _resolve_portacode_key_dir(vmid: int, user: str) -> str:
|
|
747
|
-
data_dir_cmd =
|
|
977
|
+
data_dir_cmd = _su_command(user, "echo -n ${XDG_DATA_HOME:-$HOME/.local/share}")
|
|
748
978
|
data_home = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
|
|
749
979
|
portacode_dir = f"{data_home}/portacode"
|
|
750
980
|
_run_pct_exec_check(vmid, ["mkdir", "-p", portacode_dir])
|
|
@@ -761,18 +991,19 @@ def _deploy_device_keypair(vmid: int, user: str, private_key: str, public_key: s
|
|
|
761
991
|
|
|
762
992
|
|
|
763
993
|
def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
|
|
764
|
-
|
|
994
|
+
su_connect_cmd = _su_command(user, "portacode connect")
|
|
995
|
+
cmd = ["pct", "exec", str(vmid), "--", "/bin/sh", "-c", su_connect_cmd]
|
|
765
996
|
proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
766
997
|
start = time.time()
|
|
767
998
|
|
|
768
|
-
data_dir_cmd =
|
|
999
|
+
data_dir_cmd = _su_command(user, "echo -n ${XDG_DATA_HOME:-$HOME/.local/share}")
|
|
769
1000
|
data_dir = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
|
|
770
1001
|
key_dir = f"{data_dir}/portacode/keys"
|
|
771
1002
|
pub_path = f"{key_dir}/id_portacode.pub"
|
|
772
1003
|
priv_path = f"{key_dir}/id_portacode"
|
|
773
1004
|
|
|
774
1005
|
def file_size(path: str) -> Optional[int]:
|
|
775
|
-
stat_cmd = f"
|
|
1006
|
+
stat_cmd = _su_command(user, f"test -s {path} && stat -c %s {path}")
|
|
776
1007
|
res = _run_pct(vmid, stat_cmd)
|
|
777
1008
|
if res["returncode"] != 0:
|
|
778
1009
|
return None
|
|
@@ -830,7 +1061,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
|
|
|
830
1061
|
final_pub = file_size(pub_path)
|
|
831
1062
|
final_priv = file_size(priv_path)
|
|
832
1063
|
if final_pub and final_priv:
|
|
833
|
-
key_res = _run_pct(vmid, f"
|
|
1064
|
+
key_res = _run_pct(vmid, _su_command(user, f"cat {pub_path}"))
|
|
834
1065
|
if not process_exited:
|
|
835
1066
|
proc.terminate()
|
|
836
1067
|
try:
|
|
@@ -870,7 +1101,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
|
|
|
870
1101
|
except subprocess.TimeoutExpired:
|
|
871
1102
|
proc.kill()
|
|
872
1103
|
|
|
873
|
-
key_res = _run_pct(vmid, f"
|
|
1104
|
+
key_res = _run_pct(vmid, _su_command(user, f"cat {pub_path}"))
|
|
874
1105
|
return {
|
|
875
1106
|
"ok": True,
|
|
876
1107
|
"public_key": key_res["stdout"].strip(),
|
|
@@ -963,7 +1194,16 @@ def _bootstrap_portacode(
|
|
|
963
1194
|
total_steps: Optional[int] = None,
|
|
964
1195
|
default_public_key: Optional[str] = None,
|
|
965
1196
|
) -> Tuple[str, List[Dict[str, Any]]]:
|
|
966
|
-
|
|
1197
|
+
if steps is not None:
|
|
1198
|
+
actual_steps = steps
|
|
1199
|
+
else:
|
|
1200
|
+
detected_manager = _detect_package_manager(vmid)
|
|
1201
|
+
actual_steps = _build_bootstrap_steps(
|
|
1202
|
+
user,
|
|
1203
|
+
password,
|
|
1204
|
+
ssh_key,
|
|
1205
|
+
package_manager=detected_manager,
|
|
1206
|
+
)
|
|
967
1207
|
results, ok = _run_setup_steps(
|
|
968
1208
|
vmid,
|
|
969
1209
|
actual_steps,
|
|
@@ -987,6 +1227,15 @@ def _bootstrap_portacode(
|
|
|
987
1227
|
else:
|
|
988
1228
|
command_text = str(command)
|
|
989
1229
|
command_suffix = f" command={command_text}" if command_text else ""
|
|
1230
|
+
stdout = details.get("stdout")
|
|
1231
|
+
stderr = details.get("stderr")
|
|
1232
|
+
if stdout or stderr:
|
|
1233
|
+
logger.debug(
|
|
1234
|
+
"Bootstrap command output%s%s%s",
|
|
1235
|
+
f" stdout={stdout!r}" if stdout else "",
|
|
1236
|
+
" " if stdout and stderr else "",
|
|
1237
|
+
f"stderr={stderr!r}" if stderr else "",
|
|
1238
|
+
)
|
|
990
1239
|
if summary:
|
|
991
1240
|
logger.warning(
|
|
992
1241
|
"Portacode bootstrap failure summary=%s%s%s",
|
|
@@ -994,10 +1243,15 @@ def _bootstrap_portacode(
|
|
|
994
1243
|
f" history_len={len(history)}" if history else "",
|
|
995
1244
|
f" command={command_text}" if command_text else "",
|
|
996
1245
|
)
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1246
|
+
logger.error(
|
|
1247
|
+
"Portacode bootstrap command failed%s%s%s",
|
|
1248
|
+
f" command={command_text}" if command_text else "",
|
|
1249
|
+
f" stdout={stdout!r}" if stdout else "",
|
|
1250
|
+
f" stderr={stderr!r}" if stderr else "",
|
|
1251
|
+
)
|
|
1252
|
+
raise RuntimeError(
|
|
1253
|
+
f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
|
|
1254
|
+
)
|
|
1001
1255
|
key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
|
|
1002
1256
|
public_key = key_step.get("public_key") if key_step else default_public_key
|
|
1003
1257
|
if not public_key:
|
|
@@ -1014,6 +1268,7 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1014
1268
|
}
|
|
1015
1269
|
if not config:
|
|
1016
1270
|
return {"configured": False, "network": base_network}
|
|
1271
|
+
_ensure_templates_refreshed_on_startup(config)
|
|
1017
1272
|
return {
|
|
1018
1273
|
"configured": True,
|
|
1019
1274
|
"host": config.get("host"),
|
|
@@ -1063,8 +1318,9 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
|
|
|
1063
1318
|
"token_value": token_value,
|
|
1064
1319
|
"verify_ssl": verify_ssl,
|
|
1065
1320
|
"default_storage": default_storage,
|
|
1066
|
-
"templates": templates,
|
|
1067
1321
|
"last_verified": datetime.utcnow().isoformat() + "Z",
|
|
1322
|
+
"templates": templates,
|
|
1323
|
+
"templates_last_refreshed": _current_time_iso(),
|
|
1068
1324
|
"network": network,
|
|
1069
1325
|
"node_status": status,
|
|
1070
1326
|
}
|
|
@@ -1118,7 +1374,7 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
|
|
|
1118
1374
|
memory=int(payload["ram_mib"]),
|
|
1119
1375
|
swap=int(payload.get("swap_mb", 0)),
|
|
1120
1376
|
cores=max(int(payload.get("cores", 1)), 1),
|
|
1121
|
-
|
|
1377
|
+
cpulimit=float(payload.get("cpulimit", payload.get("cpus", 1))),
|
|
1122
1378
|
net0=payload["net0"],
|
|
1123
1379
|
unprivileged=int(payload.get("unprivileged", 1)),
|
|
1124
1380
|
description=payload.get("description", MANAGED_MARKER),
|
|
@@ -1126,6 +1382,13 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
|
|
|
1126
1382
|
ssh_public_keys=payload.get("ssh_public_key") or None,
|
|
1127
1383
|
)
|
|
1128
1384
|
status, elapsed = _wait_for_task(proxmox, node, upid)
|
|
1385
|
+
exitstatus = (status or {}).get("exitstatus")
|
|
1386
|
+
if exitstatus and exitstatus.upper() != "OK":
|
|
1387
|
+
msg = status.get("status") or "unknown error"
|
|
1388
|
+
details = status.get("error") or status.get("errmsg") or status.get("description") or status
|
|
1389
|
+
raise RuntimeError(
|
|
1390
|
+
f"Container creation task failed ({exitstatus}): {msg} details={details}"
|
|
1391
|
+
)
|
|
1129
1392
|
return vmid, elapsed
|
|
1130
1393
|
except ResourceException as exc:
|
|
1131
1394
|
raise RuntimeError(f"Failed to create container: {exc}") from exc
|
|
@@ -1141,16 +1404,24 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1141
1404
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1142
1405
|
logger.info("create_proxmox_container command received")
|
|
1143
1406
|
request_id = message.get("request_id")
|
|
1144
|
-
|
|
1407
|
+
raw_device_id = message.get("device_id")
|
|
1408
|
+
device_id = str(raw_device_id or "").strip()
|
|
1409
|
+
if not device_id:
|
|
1410
|
+
raise ValueError("device_id is required to create a container")
|
|
1145
1411
|
device_public_key = (message.get("device_public_key") or "").strip()
|
|
1146
1412
|
device_private_key = (message.get("device_private_key") or "").strip()
|
|
1147
1413
|
has_device_keypair = bool(device_public_key and device_private_key)
|
|
1414
|
+
config_guess = _load_config()
|
|
1415
|
+
template_candidates = config_guess.get("templates") or []
|
|
1416
|
+
template_hint = (message.get("template") or (template_candidates[0] if template_candidates else "")).strip()
|
|
1417
|
+
package_manager = _guess_package_manager_from_template(template_hint)
|
|
1148
1418
|
bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
|
|
1149
1419
|
bootstrap_steps = _build_bootstrap_steps(
|
|
1150
1420
|
bootstrap_user,
|
|
1151
1421
|
bootstrap_password,
|
|
1152
1422
|
bootstrap_ssh_key,
|
|
1153
1423
|
include_portacode_connect=not has_device_keypair,
|
|
1424
|
+
package_manager=package_manager,
|
|
1154
1425
|
)
|
|
1155
1426
|
total_steps = 3 + len(bootstrap_steps) + 2
|
|
1156
1427
|
current_step_index = 1
|
|
@@ -1173,6 +1444,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1173
1444
|
message=start_message,
|
|
1174
1445
|
phase="lifecycle",
|
|
1175
1446
|
request_id=request_id,
|
|
1447
|
+
on_behalf_of_device=device_id,
|
|
1176
1448
|
)
|
|
1177
1449
|
try:
|
|
1178
1450
|
result = action()
|
|
@@ -1188,6 +1460,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1188
1460
|
phase="lifecycle",
|
|
1189
1461
|
request_id=request_id,
|
|
1190
1462
|
details={"error": str(exc)},
|
|
1463
|
+
on_behalf_of_device=device_id,
|
|
1191
1464
|
)
|
|
1192
1465
|
raise
|
|
1193
1466
|
_emit_progress_event(
|
|
@@ -1200,6 +1473,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1200
1473
|
message=success_message,
|
|
1201
1474
|
phase="lifecycle",
|
|
1202
1475
|
request_id=request_id,
|
|
1476
|
+
on_behalf_of_device=device_id,
|
|
1203
1477
|
)
|
|
1204
1478
|
current_step_index += 1
|
|
1205
1479
|
return result
|
|
@@ -1226,7 +1500,8 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1226
1500
|
proxmox = _connect_proxmox(config)
|
|
1227
1501
|
node = config.get("node") or DEFAULT_NODE_NAME
|
|
1228
1502
|
payload = _build_container_payload(message, config)
|
|
1229
|
-
payload["
|
|
1503
|
+
payload["cpulimit"] = float(payload["cpus"])
|
|
1504
|
+
payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
|
|
1230
1505
|
payload["memory"] = int(payload["ram_mib"])
|
|
1231
1506
|
payload["node"] = node
|
|
1232
1507
|
logger.debug(
|
|
@@ -1241,6 +1516,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1241
1516
|
payload["vmid"] = vmid
|
|
1242
1517
|
payload["created_at"] = datetime.utcnow().isoformat() + "Z"
|
|
1243
1518
|
payload["status"] = "creating"
|
|
1519
|
+
payload["device_id"] = device_id
|
|
1244
1520
|
_write_container_record(vmid, payload)
|
|
1245
1521
|
return proxmox, node, vmid, payload
|
|
1246
1522
|
|
|
@@ -1301,6 +1577,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1301
1577
|
phase="bootstrap",
|
|
1302
1578
|
request_id=request_id,
|
|
1303
1579
|
details=details or None,
|
|
1580
|
+
on_behalf_of_device=device_id,
|
|
1304
1581
|
)
|
|
1305
1582
|
|
|
1306
1583
|
public_key, steps = _bootstrap_portacode(
|
|
@@ -1344,6 +1621,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1344
1621
|
message="Notifying the server of the new device…",
|
|
1345
1622
|
phase="service",
|
|
1346
1623
|
request_id=request_id,
|
|
1624
|
+
on_behalf_of_device=device_id,
|
|
1347
1625
|
)
|
|
1348
1626
|
_emit_progress_event(
|
|
1349
1627
|
self,
|
|
@@ -1355,6 +1633,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1355
1633
|
message="Authentication metadata recorded.",
|
|
1356
1634
|
phase="service",
|
|
1357
1635
|
request_id=request_id,
|
|
1636
|
+
on_behalf_of_device=device_id,
|
|
1358
1637
|
)
|
|
1359
1638
|
|
|
1360
1639
|
install_step = service_start_index + 1
|
|
@@ -1369,9 +1648,10 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1369
1648
|
message="Running sudo portacode service install…",
|
|
1370
1649
|
phase="service",
|
|
1371
1650
|
request_id=request_id,
|
|
1651
|
+
on_behalf_of_device=device_id,
|
|
1372
1652
|
)
|
|
1373
1653
|
|
|
1374
|
-
cmd =
|
|
1654
|
+
cmd = _su_command(payload["username"], "sudo -S portacode service install")
|
|
1375
1655
|
res = _run_pct(vmid, cmd, input_text=payload["password"] + "\n")
|
|
1376
1656
|
|
|
1377
1657
|
if res["returncode"] != 0:
|
|
@@ -1389,6 +1669,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1389
1669
|
"stderr": res.get("stderr"),
|
|
1390
1670
|
"stdout": res.get("stdout"),
|
|
1391
1671
|
},
|
|
1672
|
+
on_behalf_of_device=device_id,
|
|
1392
1673
|
)
|
|
1393
1674
|
raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
|
|
1394
1675
|
|
|
@@ -1402,6 +1683,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1402
1683
|
message="Portacode service install finished.",
|
|
1403
1684
|
phase="service",
|
|
1404
1685
|
request_id=request_id,
|
|
1686
|
+
on_behalf_of_device=device_id,
|
|
1405
1687
|
)
|
|
1406
1688
|
|
|
1407
1689
|
logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
|
|
@@ -1425,6 +1707,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1425
1707
|
},
|
|
1426
1708
|
"setup_steps": steps,
|
|
1427
1709
|
"device_id": device_id,
|
|
1710
|
+
"on_behalf_of_device": device_id,
|
|
1428
1711
|
"service_installed": service_installed,
|
|
1429
1712
|
}
|
|
1430
1713
|
|
|
@@ -1450,6 +1733,9 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1450
1733
|
password = record.get("password")
|
|
1451
1734
|
if not user or not password:
|
|
1452
1735
|
raise RuntimeError("Container credentials unavailable")
|
|
1736
|
+
on_behalf_of_device = record.get("device_id")
|
|
1737
|
+
if on_behalf_of_device:
|
|
1738
|
+
on_behalf_of_device = str(on_behalf_of_device)
|
|
1453
1739
|
|
|
1454
1740
|
start_index = int(message.get("step_index", 1))
|
|
1455
1741
|
total_steps = int(message.get("total_steps", start_index + 2))
|
|
@@ -1467,6 +1753,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1467
1753
|
message="Notifying the server of the new device…",
|
|
1468
1754
|
phase="service",
|
|
1469
1755
|
request_id=request_id,
|
|
1756
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1470
1757
|
)
|
|
1471
1758
|
_emit_progress_event(
|
|
1472
1759
|
self,
|
|
@@ -1478,6 +1765,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1478
1765
|
message="Authentication metadata recorded.",
|
|
1479
1766
|
phase="service",
|
|
1480
1767
|
request_id=request_id,
|
|
1768
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1481
1769
|
)
|
|
1482
1770
|
|
|
1483
1771
|
install_step = start_index + 1
|
|
@@ -1492,9 +1780,10 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1492
1780
|
message="Running sudo portacode service install…",
|
|
1493
1781
|
phase="service",
|
|
1494
1782
|
request_id=request_id,
|
|
1783
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1495
1784
|
)
|
|
1496
1785
|
|
|
1497
|
-
cmd =
|
|
1786
|
+
cmd = _su_command(user, "sudo -S portacode service install")
|
|
1498
1787
|
res = _run_pct(vmid, cmd, input_text=password + "\n")
|
|
1499
1788
|
|
|
1500
1789
|
if res["returncode"] != 0:
|
|
@@ -1512,6 +1801,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1512
1801
|
"stderr": res.get("stderr"),
|
|
1513
1802
|
"stdout": res.get("stdout"),
|
|
1514
1803
|
},
|
|
1804
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1515
1805
|
)
|
|
1516
1806
|
raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
|
|
1517
1807
|
|
|
@@ -1525,6 +1815,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1525
1815
|
message="Portacode service install finished.",
|
|
1526
1816
|
phase="service",
|
|
1527
1817
|
request_id=request_id,
|
|
1818
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1528
1819
|
)
|
|
1529
1820
|
|
|
1530
1821
|
return {
|
portacode/connection/terminal.py
CHANGED
|
@@ -685,6 +685,7 @@ class TerminalManager:
|
|
|
685
685
|
try:
|
|
686
686
|
# Initialize project state
|
|
687
687
|
project_state = await manager.initialize_project_state(session_name, project_folder_path)
|
|
688
|
+
await self._restore_tabs_from_session_metadata(manager, project_state, session)
|
|
688
689
|
|
|
689
690
|
# Send initial project state to the client
|
|
690
691
|
initial_state_payload = {
|
|
@@ -710,6 +711,85 @@ class TerminalManager:
|
|
|
710
711
|
|
|
711
712
|
except Exception as exc:
|
|
712
713
|
logger.exception("terminal_manager: Error initializing project states for new sessions: %s", exc)
|
|
714
|
+
|
|
715
|
+
async def _restore_tabs_from_session_metadata(self, manager, project_state, session):
|
|
716
|
+
"""Restore open tabs/active tab from client session metadata if available."""
|
|
717
|
+
if not session or not project_state:
|
|
718
|
+
return
|
|
719
|
+
|
|
720
|
+
descriptors = session.get("open_tabs") or []
|
|
721
|
+
if not descriptors:
|
|
722
|
+
return
|
|
723
|
+
|
|
724
|
+
session_id = project_state.client_session_id
|
|
725
|
+
logger.info("terminal_manager: 🧭 Restoring %d tabs from metadata for session %s", len(descriptors), session_id)
|
|
726
|
+
|
|
727
|
+
for descriptor in descriptors:
|
|
728
|
+
parsed = self._parse_tab_descriptor(descriptor)
|
|
729
|
+
if not parsed:
|
|
730
|
+
continue
|
|
731
|
+
|
|
732
|
+
tab_type = parsed.get("tab_type")
|
|
733
|
+
file_path = parsed.get("file_path")
|
|
734
|
+
metadata = parsed.get("metadata", {})
|
|
735
|
+
|
|
736
|
+
if tab_type == "file" and file_path:
|
|
737
|
+
try:
|
|
738
|
+
await manager.open_file(session_id, file_path, set_active=False)
|
|
739
|
+
except Exception as exc:
|
|
740
|
+
logger.warning("terminal_manager: Failed to restore file tab %s for session %s: %s", file_path, session_id, exc)
|
|
741
|
+
continue
|
|
742
|
+
|
|
743
|
+
if tab_type == "diff" and file_path:
|
|
744
|
+
from_ref = metadata.get("from") or metadata.get("from_ref")
|
|
745
|
+
to_ref = metadata.get("to") or metadata.get("to_ref")
|
|
746
|
+
if not from_ref or not to_ref:
|
|
747
|
+
logger.warning("terminal_manager: Skipping diff tab %s for session %s because from/to references are missing", file_path, session_id)
|
|
748
|
+
continue
|
|
749
|
+
from_hash = metadata.get("from_hash") or metadata.get("fromHash")
|
|
750
|
+
to_hash = metadata.get("to_hash") or metadata.get("toHash")
|
|
751
|
+
try:
|
|
752
|
+
await manager.open_diff_tab(session_id, file_path, from_ref, to_ref, from_hash=from_hash, to_hash=to_hash)
|
|
753
|
+
except Exception as exc:
|
|
754
|
+
logger.warning("terminal_manager: Failed to restore diff tab %s for session %s: %s", file_path, session_id, exc)
|
|
755
|
+
continue
|
|
756
|
+
|
|
757
|
+
logger.debug("terminal_manager: Unknown tab descriptor ignored for session %s: %s", session_id, descriptor)
|
|
758
|
+
|
|
759
|
+
active_index = session.get("active_tab")
|
|
760
|
+
try:
|
|
761
|
+
active_index_int = int(active_index) if active_index is not None else None
|
|
762
|
+
except (TypeError, ValueError):
|
|
763
|
+
active_index_int = None
|
|
764
|
+
|
|
765
|
+
if active_index_int is not None and active_index_int >= 0:
|
|
766
|
+
current_tabs = list(project_state.open_tabs.values())
|
|
767
|
+
if 0 <= active_index_int < len(current_tabs):
|
|
768
|
+
try:
|
|
769
|
+
await manager.set_active_tab(session_id, current_tabs[active_index_int].tab_id)
|
|
770
|
+
except Exception as exc:
|
|
771
|
+
logger.warning("terminal_manager: Failed to set active tab for session %s: %s", session_id, exc)
|
|
772
|
+
else:
|
|
773
|
+
logger.debug("terminal_manager: Active tab index %s out of range for session %s", active_index_int, session_id)
|
|
774
|
+
|
|
775
|
+
def _parse_tab_descriptor(self, descriptor: str) -> Optional[Dict[str, Any]]:
|
|
776
|
+
"""Parse a URL-friendly tab descriptor string."""
|
|
777
|
+
if not descriptor:
|
|
778
|
+
return None
|
|
779
|
+
|
|
780
|
+
try:
|
|
781
|
+
parts = descriptor.split("|")
|
|
782
|
+
tab_type = parts[0] if parts else None
|
|
783
|
+
file_path = parts[1] if len(parts) > 1 else None
|
|
784
|
+
metadata = {}
|
|
785
|
+
for part in parts[2:]:
|
|
786
|
+
if "=" in part:
|
|
787
|
+
key, value = part.split("=", 1)
|
|
788
|
+
metadata[key] = value
|
|
789
|
+
return {"tab_type": tab_type, "file_path": file_path, "metadata": metadata}
|
|
790
|
+
except Exception as exc:
|
|
791
|
+
logger.warning("terminal_manager: Failed to parse tab descriptor '%s': %s", descriptor, exc)
|
|
792
|
+
return None
|
|
713
793
|
|
|
714
794
|
async def _send_targeted_terminal_list(self, message: Dict[str, Any], target_sessions: List[str]) -> None:
|
|
715
795
|
"""Send terminal_list command to specific client sessions.
|
|
@@ -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=etxcB5M9Bu6fDP8xB_u0QGAYtqm9iTbzbI4qxCPucxs,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
|
|
@@ -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=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=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,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=DE2TUPKwW4bZ9L7vxyE_m74-ra0JR9DfUCkUOPg5tpk,71908
|
|
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
|
|
@@ -35,7 +35,7 @@ portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL
|
|
|
35
35
|
portacode/connection/handlers/project_state/file_system_watcher.py,sha256=r9_UKxWTbzum0jGqxIafe68Ced2Y3xOp3ZmkpBOfRpw,8573
|
|
36
36
|
portacode/connection/handlers/project_state/git_manager.py,sha256=iGQ7LYIA7uHsZHdj3HEc_LYo7S1Lqv6-AeyyMwknBPo,70027
|
|
37
37
|
portacode/connection/handlers/project_state/handlers.py,sha256=qgOSt26rxAGNxW07AoevTwDPBdxblX4J-dX-EjOKtg4,38232
|
|
38
|
-
portacode/connection/handlers/project_state/manager.py,sha256=
|
|
38
|
+
portacode/connection/handlers/project_state/manager.py,sha256=ori_QpeoY1sdpY8WDYIx-kl_gNfZ7o8eq84CcOBlvIs,67915
|
|
39
39
|
portacode/connection/handlers/project_state/models.py,sha256=EZTKvxHKs8QlQUbzI0u2IqfzfRRXZixUIDBwTGCJATI,4313
|
|
40
40
|
portacode/connection/handlers/project_state/utils.py,sha256=LsbQr9TH9Bz30FqikmtTxco4PlB_n0kUIuPKQ6Fb_mo,1665
|
|
41
41
|
portacode/link_capture/__init__.py,sha256=93LjyYDqzOimsIDBhsPibTl7tr-8DiIzyDF7JWQkE2A,1231
|
|
@@ -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.15.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.15.dist-info/METADATA,sha256=RFm2fBFzusB-xJHakI-C1nr-5_lNUhRwFQea5lg7sX8,13046
|
|
95
|
+
portacode-1.4.15.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
96
|
+
portacode-1.4.15.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
|
|
97
|
+
portacode-1.4.15.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
|
|
98
|
+
portacode-1.4.15.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|