portacode 1.4.11.dev5__py3-none-any.whl → 1.4.12.dev3__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.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +2 -2
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +142 -15
- portacode/connection/handlers/__init__.py +8 -0
- portacode/connection/handlers/proxmox_infra.py +743 -116
- portacode/connection/terminal.py +9 -0
- {portacode-1.4.11.dev5.dist-info → portacode-1.4.12.dev3.dist-info}/METADATA +1 -1
- {portacode-1.4.11.dev5.dist-info → portacode-1.4.12.dev3.dist-info}/RECORD +11 -11
- {portacode-1.4.11.dev5.dist-info → portacode-1.4.12.dev3.dist-info}/WHEEL +0 -0
- {portacode-1.4.11.dev5.dist-info → portacode-1.4.12.dev3.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.11.dev5.dist-info → portacode-1.4.12.dev3.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.11.dev5.dist-info → portacode-1.4.12.dev3.dist-info}/top_level.txt +0 -0
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import json
|
|
6
7
|
import logging
|
|
7
8
|
import os
|
|
9
|
+
import secrets
|
|
8
10
|
import shutil
|
|
9
11
|
import stat
|
|
10
12
|
import subprocess
|
|
11
13
|
import sys
|
|
12
14
|
import time
|
|
15
|
+
import threading
|
|
13
16
|
from datetime import datetime
|
|
14
17
|
from pathlib import Path
|
|
15
|
-
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
18
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
|
16
19
|
|
|
17
20
|
import platformdirs
|
|
18
21
|
|
|
@@ -38,6 +41,58 @@ DNS_SERVER = "1.1.1.1"
|
|
|
38
41
|
IFACES_PATH = Path("/etc/network/interfaces")
|
|
39
42
|
SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
|
|
40
43
|
UNIT_DIR = Path("/etc/systemd/system")
|
|
44
|
+
_MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
|
|
45
|
+
_MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
|
|
46
|
+
_MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
|
|
47
|
+
|
|
48
|
+
ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _emit_progress_event(
|
|
52
|
+
handler: SyncHandler,
|
|
53
|
+
*,
|
|
54
|
+
step_index: int,
|
|
55
|
+
total_steps: int,
|
|
56
|
+
step_name: str,
|
|
57
|
+
step_label: str,
|
|
58
|
+
status: str,
|
|
59
|
+
message: str,
|
|
60
|
+
phase: str,
|
|
61
|
+
request_id: Optional[str],
|
|
62
|
+
details: Optional[Dict[str, Any]] = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
loop = handler.context.get("event_loop")
|
|
65
|
+
if not loop or loop.is_closed():
|
|
66
|
+
logger.debug(
|
|
67
|
+
"progress event skipped (no event loop) step=%s status=%s",
|
|
68
|
+
step_name,
|
|
69
|
+
status,
|
|
70
|
+
)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
payload: Dict[str, Any] = {
|
|
74
|
+
"event": "proxmox_container_progress",
|
|
75
|
+
"step_name": step_name,
|
|
76
|
+
"step_label": step_label,
|
|
77
|
+
"status": status,
|
|
78
|
+
"phase": phase,
|
|
79
|
+
"step_index": step_index,
|
|
80
|
+
"total_steps": total_steps,
|
|
81
|
+
"message": message,
|
|
82
|
+
}
|
|
83
|
+
if request_id:
|
|
84
|
+
payload["request_id"] = request_id
|
|
85
|
+
if details:
|
|
86
|
+
payload["details"] = details
|
|
87
|
+
|
|
88
|
+
future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
|
|
89
|
+
future.add_done_callback(
|
|
90
|
+
lambda fut: logger.warning(
|
|
91
|
+
"Failed to emit progress event for %s: %s", step_name, fut.exception()
|
|
92
|
+
)
|
|
93
|
+
if fut.exception()
|
|
94
|
+
else None
|
|
95
|
+
)
|
|
41
96
|
|
|
42
97
|
|
|
43
98
|
def _call_subprocess(cmd: List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
|
@@ -239,12 +294,185 @@ def _ensure_containers_dir() -> None:
|
|
|
239
294
|
CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
|
|
240
295
|
|
|
241
296
|
|
|
297
|
+
def _invalidate_managed_containers_cache() -> None:
|
|
298
|
+
with _MANAGED_CONTAINERS_CACHE_LOCK:
|
|
299
|
+
_MANAGED_CONTAINERS_CACHE["timestamp"] = 0.0
|
|
300
|
+
_MANAGED_CONTAINERS_CACHE["summary"] = None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _load_managed_container_records() -> List[Dict[str, Any]]:
|
|
304
|
+
_ensure_containers_dir()
|
|
305
|
+
records: List[Dict[str, Any]] = []
|
|
306
|
+
for path in sorted(CONTAINERS_DIR.glob("ct-*.json")):
|
|
307
|
+
try:
|
|
308
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
309
|
+
except Exception as exc: # pragma: no cover - best effort logging
|
|
310
|
+
logger.debug("Unable to read container record %s: %s", path, exc)
|
|
311
|
+
continue
|
|
312
|
+
records.append(payload)
|
|
313
|
+
return records
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
317
|
+
total_ram = 0
|
|
318
|
+
total_disk = 0
|
|
319
|
+
total_cpu_share = 0.0
|
|
320
|
+
containers: List[Dict[str, Any]] = []
|
|
321
|
+
|
|
322
|
+
def _as_int(value: Any) -> int:
|
|
323
|
+
try:
|
|
324
|
+
return int(value)
|
|
325
|
+
except (TypeError, ValueError):
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
def _as_float(value: Any) -> float:
|
|
329
|
+
try:
|
|
330
|
+
return float(value)
|
|
331
|
+
except (TypeError, ValueError):
|
|
332
|
+
return 0.0
|
|
333
|
+
|
|
334
|
+
for record in sorted(records, key=lambda entry: _as_int(entry.get("vmid"))):
|
|
335
|
+
ram_mib = _as_int(record.get("ram_mib"))
|
|
336
|
+
disk_gib = _as_int(record.get("disk_gib"))
|
|
337
|
+
cpu_share = _as_float(record.get("cpus"))
|
|
338
|
+
total_ram += ram_mib
|
|
339
|
+
total_disk += disk_gib
|
|
340
|
+
total_cpu_share += cpu_share
|
|
341
|
+
status = (record.get("status") or "unknown").lower()
|
|
342
|
+
containers.append(
|
|
343
|
+
{
|
|
344
|
+
"vmid": str(_as_int(record.get("vmid"))) if record.get("vmid") is not None else None,
|
|
345
|
+
"hostname": record.get("hostname"),
|
|
346
|
+
"template": record.get("template"),
|
|
347
|
+
"storage": record.get("storage"),
|
|
348
|
+
"disk_gib": disk_gib,
|
|
349
|
+
"ram_mib": ram_mib,
|
|
350
|
+
"cpu_share": cpu_share,
|
|
351
|
+
"created_at": record.get("created_at"),
|
|
352
|
+
"status": status,
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
"updated_at": datetime.utcnow().isoformat() + "Z",
|
|
358
|
+
"count": len(containers),
|
|
359
|
+
"total_ram_mib": total_ram,
|
|
360
|
+
"total_disk_gib": total_disk,
|
|
361
|
+
"total_cpu_share": round(total_cpu_share, 2),
|
|
362
|
+
"containers": containers,
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
|
|
367
|
+
def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
|
|
368
|
+
if not records or not config:
|
|
369
|
+
return
|
|
370
|
+
try:
|
|
371
|
+
proxmox = _connect_proxmox(config)
|
|
372
|
+
node = _get_node_from_config(config)
|
|
373
|
+
statuses = {
|
|
374
|
+
str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
|
|
375
|
+
for ct in proxmox.nodes(node).lxc.get()
|
|
376
|
+
}
|
|
377
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
378
|
+
logger.debug("Failed to refresh container statuses: %s", exc)
|
|
379
|
+
return
|
|
380
|
+
for record in records:
|
|
381
|
+
vmid = record.get("vmid")
|
|
382
|
+
if vmid is None:
|
|
383
|
+
continue
|
|
384
|
+
try:
|
|
385
|
+
vmid_key = str(int(vmid))
|
|
386
|
+
except (ValueError, TypeError):
|
|
387
|
+
continue
|
|
388
|
+
status = statuses.get(vmid_key)
|
|
389
|
+
if status:
|
|
390
|
+
record["status"] = status
|
|
391
|
+
|
|
392
|
+
now = time.monotonic()
|
|
393
|
+
with _MANAGED_CONTAINERS_CACHE_LOCK:
|
|
394
|
+
cache_ts = _MANAGED_CONTAINERS_CACHE["timestamp"]
|
|
395
|
+
cached = _MANAGED_CONTAINERS_CACHE["summary"]
|
|
396
|
+
if not force and cached and now - cache_ts < _MANAGED_CONTAINERS_CACHE_TTL_S:
|
|
397
|
+
return cached
|
|
398
|
+
config = _load_config()
|
|
399
|
+
records = _load_managed_container_records()
|
|
400
|
+
_refresh_container_statuses(records, config)
|
|
401
|
+
summary = _build_managed_containers_summary(records)
|
|
402
|
+
with _MANAGED_CONTAINERS_CACHE_LOCK:
|
|
403
|
+
_MANAGED_CONTAINERS_CACHE["timestamp"] = now
|
|
404
|
+
_MANAGED_CONTAINERS_CACHE["summary"] = summary
|
|
405
|
+
return summary
|
|
406
|
+
|
|
407
|
+
|
|
242
408
|
def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
|
|
243
409
|
if storage_type in ("lvm", "lvmthin"):
|
|
244
410
|
return f"{storage}:{disk_gib}"
|
|
245
411
|
return f"{storage}:{disk_gib}G"
|
|
246
412
|
|
|
247
413
|
|
|
414
|
+
def _get_provisioning_user_info(message: Dict[str, Any]) -> Tuple[str, str, str]:
|
|
415
|
+
user = (message.get("username") or "svcuser").strip() if message else "svcuser"
|
|
416
|
+
user = user or "svcuser"
|
|
417
|
+
password = message.get("password")
|
|
418
|
+
if not password:
|
|
419
|
+
password = secrets.token_urlsafe(10)
|
|
420
|
+
ssh_key = (message.get("ssh_key") or "").strip() if message else ""
|
|
421
|
+
return user, password, ssh_key
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _friendly_step_label(step_name: str) -> str:
|
|
425
|
+
if not step_name:
|
|
426
|
+
return "Step"
|
|
427
|
+
normalized = step_name.replace("_", " ").strip()
|
|
428
|
+
return normalized.capitalize()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _build_bootstrap_steps(user: str, password: str, ssh_key: str) -> List[Dict[str, Any]]:
|
|
432
|
+
steps = [
|
|
433
|
+
{
|
|
434
|
+
"name": "apt_update",
|
|
435
|
+
"cmd": "apt-get update -y",
|
|
436
|
+
"retries": 4,
|
|
437
|
+
"retry_delay_s": 5,
|
|
438
|
+
"retry_on": [
|
|
439
|
+
"Temporary failure resolving",
|
|
440
|
+
"Could not resolve",
|
|
441
|
+
"Failed to fetch",
|
|
442
|
+
],
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
"name": "install_deps",
|
|
446
|
+
"cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
|
|
447
|
+
"retries": 5,
|
|
448
|
+
"retry_delay_s": 5,
|
|
449
|
+
"retry_on": [
|
|
450
|
+
"lock-frontend",
|
|
451
|
+
"Unable to acquire the dpkg frontend lock",
|
|
452
|
+
"Temporary failure resolving",
|
|
453
|
+
"Could not resolve",
|
|
454
|
+
"Failed to fetch",
|
|
455
|
+
],
|
|
456
|
+
},
|
|
457
|
+
{"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
|
|
458
|
+
{"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
|
|
459
|
+
]
|
|
460
|
+
if password:
|
|
461
|
+
steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
|
|
462
|
+
if ssh_key:
|
|
463
|
+
steps.append({
|
|
464
|
+
"name": "add_ssh_key",
|
|
465
|
+
"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
|
+
"retries": 0,
|
|
467
|
+
})
|
|
468
|
+
steps.extend([
|
|
469
|
+
{"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
|
|
470
|
+
{"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
|
|
471
|
+
{"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30},
|
|
472
|
+
])
|
|
473
|
+
return steps
|
|
474
|
+
|
|
475
|
+
|
|
248
476
|
def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
|
|
249
477
|
for entry in storages:
|
|
250
478
|
if entry.get("storage") == storage_name:
|
|
@@ -252,14 +480,14 @@ def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) ->
|
|
|
252
480
|
return ""
|
|
253
481
|
|
|
254
482
|
|
|
255
|
-
def
|
|
483
|
+
def _validate_positive_number(value: Any, default: float) -> float:
|
|
256
484
|
try:
|
|
257
|
-
candidate =
|
|
485
|
+
candidate = float(value)
|
|
258
486
|
if candidate > 0:
|
|
259
487
|
return candidate
|
|
260
488
|
except Exception:
|
|
261
489
|
pass
|
|
262
|
-
return default
|
|
490
|
+
return float(default)
|
|
263
491
|
|
|
264
492
|
|
|
265
493
|
def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
|
|
@@ -311,10 +539,44 @@ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any]
|
|
|
311
539
|
return _wait_for_task(proxmox, node, upid)
|
|
312
540
|
|
|
313
541
|
|
|
542
|
+
def _stop_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
|
|
543
|
+
status = proxmox.nodes(node).lxc(vmid).status.current.get()
|
|
544
|
+
if status.get("status") != "running":
|
|
545
|
+
return status, 0.0
|
|
546
|
+
upid = proxmox.nodes(node).lxc(vmid).status.stop.post()
|
|
547
|
+
return _wait_for_task(proxmox, node, upid)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _delete_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
|
|
551
|
+
upid = proxmox.nodes(node).lxc(vmid).delete()
|
|
552
|
+
return _wait_for_task(proxmox, node, upid)
|
|
553
|
+
|
|
554
|
+
|
|
314
555
|
def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
|
|
315
556
|
_ensure_containers_dir()
|
|
316
557
|
path = CONTAINERS_DIR / f"ct-{vmid}.json"
|
|
317
558
|
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
559
|
+
_invalidate_managed_containers_cache()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _read_container_record(vmid: int) -> Dict[str, Any]:
|
|
563
|
+
path = CONTAINERS_DIR / f"ct-{vmid}.json"
|
|
564
|
+
if not path.exists():
|
|
565
|
+
raise FileNotFoundError(f"Container record {path} missing")
|
|
566
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _update_container_record(vmid: int, updates: Dict[str, Any]) -> None:
|
|
570
|
+
record = _read_container_record(vmid)
|
|
571
|
+
record.update(updates)
|
|
572
|
+
_write_container_record(vmid, record)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _remove_container_record(vmid: int) -> None:
|
|
576
|
+
path = CONTAINERS_DIR / f"ct-{vmid}.json"
|
|
577
|
+
if path.exists():
|
|
578
|
+
path.unlink()
|
|
579
|
+
_invalidate_managed_containers_cache()
|
|
318
580
|
|
|
319
581
|
|
|
320
582
|
def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -326,16 +588,14 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
|
|
|
326
588
|
|
|
327
589
|
bridge = config.get("network", {}).get("bridge", DEFAULT_BRIDGE)
|
|
328
590
|
hostname = (message.get("hostname") or "").strip()
|
|
329
|
-
disk_gib =
|
|
330
|
-
ram_mib =
|
|
331
|
-
cpus =
|
|
591
|
+
disk_gib = int(max(round(_validate_positive_number(message.get("disk_gib") or message.get("disk"), 3)), 1))
|
|
592
|
+
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"), 0.2)
|
|
332
594
|
storage = message.get("storage") or config.get("default_storage") or ""
|
|
333
595
|
if not storage:
|
|
334
596
|
raise ValueError("Storage pool could not be determined.")
|
|
335
597
|
|
|
336
|
-
user = (message
|
|
337
|
-
password = message.get("password") or ""
|
|
338
|
-
ssh_key = (message.get("ssh_key") or "").strip()
|
|
598
|
+
user, password, ssh_key = _get_provisioning_user_info(message)
|
|
339
599
|
|
|
340
600
|
payload = {
|
|
341
601
|
"template": template,
|
|
@@ -355,6 +615,38 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
|
|
|
355
615
|
return payload
|
|
356
616
|
|
|
357
617
|
|
|
618
|
+
def _ensure_infra_configured() -> Dict[str, Any]:
|
|
619
|
+
config = _load_config()
|
|
620
|
+
if not config or not config.get("token_value"):
|
|
621
|
+
raise RuntimeError("Proxmox infrastructure is not configured.")
|
|
622
|
+
return config
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _get_node_from_config(config: Dict[str, Any]) -> str:
|
|
626
|
+
return config.get("node") or DEFAULT_NODE_NAME
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _parse_ctid(message: Dict[str, Any]) -> int:
|
|
630
|
+
for key in ("ctid", "vmid"):
|
|
631
|
+
value = message.get(key)
|
|
632
|
+
if value is not None:
|
|
633
|
+
try:
|
|
634
|
+
return int(str(value).strip())
|
|
635
|
+
except ValueError:
|
|
636
|
+
raise ValueError(f"{key} must be an integer") from None
|
|
637
|
+
raise ValueError("ctid is required")
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _ensure_container_managed(
|
|
641
|
+
proxmox: Any, node: str, vmid: int
|
|
642
|
+
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
643
|
+
record = _read_container_record(vmid)
|
|
644
|
+
ct_cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
|
|
645
|
+
if not ct_cfg or MANAGED_MARKER not in (ct_cfg.get("description") or ""):
|
|
646
|
+
raise RuntimeError(f"Container {vmid} is not managed by Portacode.")
|
|
647
|
+
return record, ct_cfg
|
|
648
|
+
|
|
649
|
+
|
|
358
650
|
def _connect_proxmox(config: Dict[str, Any]) -> Any:
|
|
359
651
|
ProxmoxAPI = _ensure_proxmoxer()
|
|
360
652
|
return ProxmoxAPI(
|
|
@@ -367,10 +659,10 @@ def _connect_proxmox(config: Dict[str, Any]) -> Any:
|
|
|
367
659
|
)
|
|
368
660
|
|
|
369
661
|
|
|
370
|
-
def _run_pct(vmid: int, cmd: str) -> Dict[str, Any]:
|
|
662
|
+
def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str, Any]:
|
|
371
663
|
full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
|
|
372
664
|
start = time.time()
|
|
373
|
-
proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
665
|
+
proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, input=input_text)
|
|
374
666
|
return {
|
|
375
667
|
"cmd": cmd,
|
|
376
668
|
"returncode": proc.returncode,
|
|
@@ -471,18 +763,36 @@ def _summarize_error(res: Dict[str, Any]) -> str:
|
|
|
471
763
|
return "Command failed; see stdout/stderr for details."
|
|
472
764
|
|
|
473
765
|
|
|
474
|
-
def _run_setup_steps(
|
|
766
|
+
def _run_setup_steps(
|
|
767
|
+
vmid: int,
|
|
768
|
+
steps: List[Dict[str, Any]],
|
|
769
|
+
user: str,
|
|
770
|
+
progress_callback: Optional[ProgressCallback] = None,
|
|
771
|
+
start_index: int = 1,
|
|
772
|
+
total_steps: Optional[int] = None,
|
|
773
|
+
) -> Tuple[List[Dict[str, Any]], bool]:
|
|
475
774
|
results: List[Dict[str, Any]] = []
|
|
476
|
-
|
|
775
|
+
computed_total = total_steps if total_steps is not None else start_index + len(steps) - 1
|
|
776
|
+
for offset, step in enumerate(steps):
|
|
777
|
+
step_index = start_index + offset
|
|
778
|
+
if progress_callback:
|
|
779
|
+
progress_callback(step_index, computed_total, step, "in_progress", None)
|
|
780
|
+
|
|
477
781
|
if step.get("type") == "portacode_connect":
|
|
478
782
|
res = _portacode_connect_and_read_key(vmid, user, timeout_s=step.get("timeout_s", 10))
|
|
479
783
|
res["name"] = step["name"]
|
|
480
784
|
results.append(res)
|
|
481
785
|
if not res.get("ok"):
|
|
786
|
+
if progress_callback:
|
|
787
|
+
progress_callback(step_index, computed_total, step, "failed", res)
|
|
482
788
|
return results, False
|
|
789
|
+
if progress_callback:
|
|
790
|
+
progress_callback(step_index, computed_total, step, "completed", res)
|
|
483
791
|
continue
|
|
484
792
|
|
|
485
793
|
attempts = 0
|
|
794
|
+
retry_on = step.get("retry_on", [])
|
|
795
|
+
max_attempts = step.get("retries", 0) + 1
|
|
486
796
|
while True:
|
|
487
797
|
attempts += 1
|
|
488
798
|
res = _run_pct(vmid, step["cmd"])
|
|
@@ -492,61 +802,47 @@ def _run_setup_steps(vmid: int, steps: List[Dict[str, Any]], user: str) -> Tuple
|
|
|
492
802
|
res["error_summary"] = _summarize_error(res)
|
|
493
803
|
results.append(res)
|
|
494
804
|
if res["returncode"] == 0:
|
|
805
|
+
if progress_callback:
|
|
806
|
+
progress_callback(step_index, computed_total, step, "completed", res)
|
|
495
807
|
break
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
808
|
+
|
|
809
|
+
will_retry = False
|
|
810
|
+
if attempts < max_attempts and retry_on:
|
|
811
|
+
stderr_stdout = (res.get("stderr", "") + res.get("stdout", ""))
|
|
812
|
+
if any(tok in stderr_stdout for tok in retry_on):
|
|
813
|
+
will_retry = True
|
|
814
|
+
|
|
815
|
+
if progress_callback:
|
|
816
|
+
status = "retrying" if will_retry else "failed"
|
|
817
|
+
progress_callback(step_index, computed_total, step, status, res)
|
|
818
|
+
|
|
819
|
+
if will_retry:
|
|
500
820
|
time.sleep(step.get("retry_delay_s", 3))
|
|
501
821
|
continue
|
|
822
|
+
|
|
502
823
|
return results, False
|
|
503
824
|
return results, True
|
|
504
825
|
|
|
505
826
|
|
|
506
|
-
def _bootstrap_portacode(
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
"lock-frontend",
|
|
526
|
-
"Unable to acquire the dpkg frontend lock",
|
|
527
|
-
"Temporary failure resolving",
|
|
528
|
-
"Could not resolve",
|
|
529
|
-
"Failed to fetch",
|
|
530
|
-
],
|
|
531
|
-
},
|
|
532
|
-
{"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
|
|
533
|
-
{"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
|
|
534
|
-
]
|
|
535
|
-
if password:
|
|
536
|
-
steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
|
|
537
|
-
if ssh_key:
|
|
538
|
-
steps.append({
|
|
539
|
-
"name": "add_ssh_key",
|
|
540
|
-
"cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
|
|
541
|
-
"retries": 0,
|
|
542
|
-
})
|
|
543
|
-
steps.extend([
|
|
544
|
-
{"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
|
|
545
|
-
{"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
|
|
546
|
-
{"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30},
|
|
547
|
-
])
|
|
548
|
-
|
|
549
|
-
results, ok = _run_setup_steps(vmid, steps, user)
|
|
827
|
+
def _bootstrap_portacode(
|
|
828
|
+
vmid: int,
|
|
829
|
+
user: str,
|
|
830
|
+
password: str,
|
|
831
|
+
ssh_key: str,
|
|
832
|
+
steps: Optional[List[Dict[str, Any]]] = None,
|
|
833
|
+
progress_callback: Optional[ProgressCallback] = None,
|
|
834
|
+
start_index: int = 1,
|
|
835
|
+
total_steps: Optional[int] = None,
|
|
836
|
+
) -> Tuple[str, List[Dict[str, Any]]]:
|
|
837
|
+
actual_steps = steps if steps is not None else _build_bootstrap_steps(user, password, ssh_key)
|
|
838
|
+
results, ok = _run_setup_steps(
|
|
839
|
+
vmid,
|
|
840
|
+
actual_steps,
|
|
841
|
+
user,
|
|
842
|
+
progress_callback=progress_callback,
|
|
843
|
+
start_index=start_index,
|
|
844
|
+
total_steps=total_steps,
|
|
845
|
+
)
|
|
550
846
|
if not ok:
|
|
551
847
|
raise RuntimeError("Portacode bootstrap steps failed.")
|
|
552
848
|
key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
|
|
@@ -626,6 +922,7 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
|
|
|
626
922
|
_save_config(config)
|
|
627
923
|
snapshot = build_snapshot(config)
|
|
628
924
|
snapshot["node_status"] = status
|
|
925
|
+
snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
|
|
629
926
|
return snapshot
|
|
630
927
|
|
|
631
928
|
|
|
@@ -634,6 +931,7 @@ def get_infra_snapshot() -> Dict[str, Any]:
|
|
|
634
931
|
snapshot = build_snapshot(config)
|
|
635
932
|
if config.get("node_status"):
|
|
636
933
|
snapshot["node_status"] = config["node_status"]
|
|
934
|
+
snapshot["managed_containers"] = _get_managed_containers_summary()
|
|
637
935
|
return snapshot
|
|
638
936
|
|
|
639
937
|
|
|
@@ -646,6 +944,7 @@ def revert_infrastructure() -> Dict[str, Any]:
|
|
|
646
944
|
snapshot["network"]["applied"] = False
|
|
647
945
|
snapshot["network"]["message"] = "Reverted to previous network state"
|
|
648
946
|
snapshot["network"]["bridge"] = DEFAULT_BRIDGE
|
|
947
|
+
snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
|
|
649
948
|
return snapshot
|
|
650
949
|
|
|
651
950
|
|
|
@@ -661,22 +960,22 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
|
|
|
661
960
|
vmid = _allocate_vmid(proxmox)
|
|
662
961
|
if not payload.get("hostname"):
|
|
663
962
|
payload["hostname"] = f"ct{vmid}"
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
963
|
+
try:
|
|
964
|
+
upid = proxmox.nodes(node).lxc.create(
|
|
965
|
+
vmid=vmid,
|
|
966
|
+
hostname=payload["hostname"],
|
|
967
|
+
ostemplate=payload["template"],
|
|
968
|
+
rootfs=rootfs,
|
|
969
|
+
memory=int(payload["ram_mib"]),
|
|
970
|
+
swap=int(payload.get("swap_mb", 0)),
|
|
971
|
+
cores=max(int(payload.get("cores", 1)), 1),
|
|
972
|
+
cpuunits=int(payload.get("cpuunits", 256)),
|
|
973
|
+
net0=payload["net0"],
|
|
974
|
+
unprivileged=int(payload.get("unprivileged", 1)),
|
|
975
|
+
description=payload.get("description", MANAGED_MARKER),
|
|
976
|
+
password=payload.get("password") or None,
|
|
977
|
+
ssh_public_keys=payload.get("ssh_public_key") or None,
|
|
978
|
+
)
|
|
680
979
|
status, elapsed = _wait_for_task(proxmox, node, upid)
|
|
681
980
|
return vmid, elapsed
|
|
682
981
|
except ResourceException as exc:
|
|
@@ -692,49 +991,171 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
692
991
|
|
|
693
992
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
694
993
|
logger.info("create_proxmox_container command received")
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
994
|
+
request_id = message.get("request_id")
|
|
995
|
+
bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
|
|
996
|
+
bootstrap_steps = _build_bootstrap_steps(bootstrap_user, bootstrap_password, bootstrap_ssh_key)
|
|
997
|
+
total_steps = 3 + len(bootstrap_steps) + 2
|
|
998
|
+
current_step_index = 1
|
|
999
|
+
|
|
1000
|
+
def _run_lifecycle_step(
|
|
1001
|
+
step_name: str,
|
|
1002
|
+
step_label: str,
|
|
1003
|
+
start_message: str,
|
|
1004
|
+
success_message: str,
|
|
1005
|
+
action,
|
|
1006
|
+
):
|
|
1007
|
+
nonlocal current_step_index
|
|
1008
|
+
step_index = current_step_index
|
|
1009
|
+
_emit_progress_event(self,
|
|
1010
|
+
step_index=step_index,
|
|
1011
|
+
total_steps=total_steps,
|
|
1012
|
+
step_name=step_name,
|
|
1013
|
+
step_label=step_label,
|
|
1014
|
+
status="in_progress",
|
|
1015
|
+
message=start_message,
|
|
1016
|
+
phase="lifecycle",
|
|
1017
|
+
request_id=request_id,
|
|
1018
|
+
)
|
|
1019
|
+
try:
|
|
1020
|
+
result = action()
|
|
1021
|
+
except Exception as exc:
|
|
1022
|
+
_emit_progress_event(
|
|
1023
|
+
self,
|
|
1024
|
+
step_index=step_index,
|
|
1025
|
+
total_steps=total_steps,
|
|
1026
|
+
step_name=step_name,
|
|
1027
|
+
step_label=step_label,
|
|
1028
|
+
status="failed",
|
|
1029
|
+
message=f"{step_label} failed: {exc}",
|
|
1030
|
+
phase="lifecycle",
|
|
1031
|
+
request_id=request_id,
|
|
1032
|
+
details={"error": str(exc)},
|
|
1033
|
+
)
|
|
1034
|
+
raise
|
|
1035
|
+
_emit_progress_event(
|
|
1036
|
+
self,
|
|
1037
|
+
step_index=step_index,
|
|
1038
|
+
total_steps=total_steps,
|
|
1039
|
+
step_name=step_name,
|
|
1040
|
+
step_label=step_label,
|
|
1041
|
+
status="completed",
|
|
1042
|
+
message=success_message,
|
|
1043
|
+
phase="lifecycle",
|
|
1044
|
+
request_id=request_id,
|
|
1045
|
+
)
|
|
1046
|
+
current_step_index += 1
|
|
1047
|
+
return result
|
|
1048
|
+
|
|
1049
|
+
def _validate_environment():
|
|
1050
|
+
if os.geteuid() != 0:
|
|
1051
|
+
raise PermissionError("Container creation requires root privileges.")
|
|
1052
|
+
config = _load_config()
|
|
1053
|
+
if not config or not config.get("token_value"):
|
|
1054
|
+
raise ValueError("Proxmox infrastructure is not configured.")
|
|
1055
|
+
if not config.get("network", {}).get("applied"):
|
|
1056
|
+
raise RuntimeError("Proxmox bridge setup must be applied before creating containers.")
|
|
1057
|
+
return config
|
|
1058
|
+
|
|
1059
|
+
config = _run_lifecycle_step(
|
|
1060
|
+
"validate_environment",
|
|
1061
|
+
"Validating infrastructure",
|
|
1062
|
+
"Checking token, permissions, and bridge setup…",
|
|
1063
|
+
"Infrastructure validated.",
|
|
1064
|
+
_validate_environment,
|
|
720
1065
|
)
|
|
721
1066
|
|
|
722
|
-
|
|
1067
|
+
def _create_container():
|
|
1068
|
+
proxmox = _connect_proxmox(config)
|
|
1069
|
+
node = config.get("node") or DEFAULT_NODE_NAME
|
|
1070
|
+
payload = _build_container_payload(message, config)
|
|
1071
|
+
payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
|
|
1072
|
+
payload["memory"] = int(payload["ram_mib"])
|
|
1073
|
+
payload["node"] = node
|
|
1074
|
+
logger.debug(
|
|
1075
|
+
"Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
|
|
1076
|
+
node,
|
|
1077
|
+
payload["template"],
|
|
1078
|
+
payload["ram_mib"],
|
|
1079
|
+
payload["cpus"],
|
|
1080
|
+
payload["storage"],
|
|
1081
|
+
)
|
|
723
1082
|
vmid, _ = _instantiate_container(proxmox, node, payload)
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1083
|
+
payload["vmid"] = vmid
|
|
1084
|
+
payload["created_at"] = datetime.utcnow().isoformat() + "Z"
|
|
1085
|
+
payload["status"] = "creating"
|
|
1086
|
+
_write_container_record(vmid, payload)
|
|
1087
|
+
return proxmox, node, vmid, payload
|
|
1088
|
+
|
|
1089
|
+
proxmox, node, vmid, payload = _run_lifecycle_step(
|
|
1090
|
+
"create_container",
|
|
1091
|
+
"Creating container",
|
|
1092
|
+
"Provisioning the LXC container…",
|
|
1093
|
+
"Container created.",
|
|
1094
|
+
_create_container,
|
|
1095
|
+
)
|
|
731
1096
|
|
|
732
|
-
|
|
1097
|
+
def _start_container_step():
|
|
733
1098
|
_start_container(proxmox, node, vmid)
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1099
|
+
|
|
1100
|
+
_run_lifecycle_step(
|
|
1101
|
+
"start_container",
|
|
1102
|
+
"Starting container",
|
|
1103
|
+
"Booting the container…",
|
|
1104
|
+
"Container startup completed.",
|
|
1105
|
+
_start_container_step,
|
|
1106
|
+
)
|
|
1107
|
+
_update_container_record(vmid, {"status": "running"})
|
|
1108
|
+
|
|
1109
|
+
def _bootstrap_progress_callback(
|
|
1110
|
+
step_index: int,
|
|
1111
|
+
total: int,
|
|
1112
|
+
step: Dict[str, Any],
|
|
1113
|
+
status: str,
|
|
1114
|
+
result: Optional[Dict[str, Any]],
|
|
1115
|
+
):
|
|
1116
|
+
label = step.get("display_name") or _friendly_step_label(step.get("name", "bootstrap"))
|
|
1117
|
+
error_summary = (result or {}).get("error_summary") or (result or {}).get("error")
|
|
1118
|
+
attempt = (result or {}).get("attempt")
|
|
1119
|
+
if status == "in_progress":
|
|
1120
|
+
message_text = f"{label} is running…"
|
|
1121
|
+
elif status == "completed":
|
|
1122
|
+
message_text = f"{label} completed."
|
|
1123
|
+
elif status == "retrying":
|
|
1124
|
+
attempt_desc = f" (attempt {attempt})" if attempt else ""
|
|
1125
|
+
message_text = f"{label} failed{attempt_desc}; retrying…"
|
|
1126
|
+
else:
|
|
1127
|
+
message_text = f"{label} failed"
|
|
1128
|
+
if error_summary:
|
|
1129
|
+
message_text += f": {error_summary}"
|
|
1130
|
+
details: Dict[str, Any] = {}
|
|
1131
|
+
if attempt:
|
|
1132
|
+
details["attempt"] = attempt
|
|
1133
|
+
if error_summary:
|
|
1134
|
+
details["error_summary"] = error_summary
|
|
1135
|
+
_emit_progress_event(
|
|
1136
|
+
self,
|
|
1137
|
+
step_index=step_index,
|
|
1138
|
+
total_steps=total,
|
|
1139
|
+
step_name=step.get("name", "bootstrap"),
|
|
1140
|
+
step_label=label,
|
|
1141
|
+
status=status,
|
|
1142
|
+
message=message_text,
|
|
1143
|
+
phase="bootstrap",
|
|
1144
|
+
request_id=request_id,
|
|
1145
|
+
details=details or None,
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
public_key, steps = _bootstrap_portacode(
|
|
1149
|
+
vmid,
|
|
1150
|
+
payload["username"],
|
|
1151
|
+
payload["password"],
|
|
1152
|
+
payload["ssh_public_key"],
|
|
1153
|
+
steps=bootstrap_steps,
|
|
1154
|
+
progress_callback=_bootstrap_progress_callback,
|
|
1155
|
+
start_index=current_step_index,
|
|
1156
|
+
total_steps=total_steps,
|
|
1157
|
+
)
|
|
1158
|
+
current_step_index += len(bootstrap_steps)
|
|
738
1159
|
|
|
739
1160
|
return {
|
|
740
1161
|
"event": "proxmox_container_created",
|
|
@@ -755,6 +1176,212 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
755
1176
|
}
|
|
756
1177
|
|
|
757
1178
|
|
|
1179
|
+
class StartPortacodeServiceHandler(SyncHandler):
|
|
1180
|
+
"""Start the Portacode service inside a newly created container."""
|
|
1181
|
+
|
|
1182
|
+
@property
|
|
1183
|
+
def command_name(self) -> str:
|
|
1184
|
+
return "start_portacode_service"
|
|
1185
|
+
|
|
1186
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1187
|
+
ctid = message.get("ctid")
|
|
1188
|
+
if not ctid:
|
|
1189
|
+
raise ValueError("ctid is required")
|
|
1190
|
+
try:
|
|
1191
|
+
vmid = int(ctid)
|
|
1192
|
+
except ValueError:
|
|
1193
|
+
raise ValueError("ctid must be an integer")
|
|
1194
|
+
|
|
1195
|
+
record = _read_container_record(vmid)
|
|
1196
|
+
user = record.get("username")
|
|
1197
|
+
password = record.get("password")
|
|
1198
|
+
if not user or not password:
|
|
1199
|
+
raise RuntimeError("Container credentials unavailable")
|
|
1200
|
+
|
|
1201
|
+
start_index = int(message.get("step_index", 1))
|
|
1202
|
+
total_steps = int(message.get("total_steps", start_index + 2))
|
|
1203
|
+
request_id = message.get("request_id")
|
|
1204
|
+
|
|
1205
|
+
auth_step_name = "setup_device_authentication"
|
|
1206
|
+
auth_label = "Setting up device authentication"
|
|
1207
|
+
_emit_progress_event(
|
|
1208
|
+
self,
|
|
1209
|
+
step_index=start_index,
|
|
1210
|
+
total_steps=total_steps,
|
|
1211
|
+
step_name=auth_step_name,
|
|
1212
|
+
step_label=auth_label,
|
|
1213
|
+
status="in_progress",
|
|
1214
|
+
message="Notifying the server of the new device…",
|
|
1215
|
+
phase="service",
|
|
1216
|
+
request_id=request_id,
|
|
1217
|
+
)
|
|
1218
|
+
_emit_progress_event(
|
|
1219
|
+
self,
|
|
1220
|
+
step_index=start_index,
|
|
1221
|
+
total_steps=total_steps,
|
|
1222
|
+
step_name=auth_step_name,
|
|
1223
|
+
step_label=auth_label,
|
|
1224
|
+
status="completed",
|
|
1225
|
+
message="Authentication metadata recorded.",
|
|
1226
|
+
phase="service",
|
|
1227
|
+
request_id=request_id,
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
install_step = start_index + 1
|
|
1231
|
+
install_label = "Launching Portacode service"
|
|
1232
|
+
_emit_progress_event(
|
|
1233
|
+
self,
|
|
1234
|
+
step_index=install_step,
|
|
1235
|
+
total_steps=total_steps,
|
|
1236
|
+
step_name="launch_portacode_service",
|
|
1237
|
+
step_label=install_label,
|
|
1238
|
+
status="in_progress",
|
|
1239
|
+
message="Running sudo portacode service install…",
|
|
1240
|
+
phase="service",
|
|
1241
|
+
request_id=request_id,
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
cmd = f"su - {user} -c 'sudo -S portacode service install'"
|
|
1245
|
+
res = _run_pct(vmid, cmd, input_text=password + "\n")
|
|
1246
|
+
|
|
1247
|
+
if res["returncode"] != 0:
|
|
1248
|
+
_emit_progress_event(
|
|
1249
|
+
self,
|
|
1250
|
+
step_index=install_step,
|
|
1251
|
+
total_steps=total_steps,
|
|
1252
|
+
step_name="launch_portacode_service",
|
|
1253
|
+
step_label=install_label,
|
|
1254
|
+
status="failed",
|
|
1255
|
+
message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
|
|
1256
|
+
phase="service",
|
|
1257
|
+
request_id=request_id,
|
|
1258
|
+
details={
|
|
1259
|
+
"stderr": res.get("stderr"),
|
|
1260
|
+
"stdout": res.get("stdout"),
|
|
1261
|
+
},
|
|
1262
|
+
)
|
|
1263
|
+
raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
|
|
1264
|
+
|
|
1265
|
+
_emit_progress_event(
|
|
1266
|
+
self,
|
|
1267
|
+
step_index=install_step,
|
|
1268
|
+
total_steps=total_steps,
|
|
1269
|
+
step_name="launch_portacode_service",
|
|
1270
|
+
step_label=install_label,
|
|
1271
|
+
status="completed",
|
|
1272
|
+
message="Portacode service install finished.",
|
|
1273
|
+
phase="service",
|
|
1274
|
+
request_id=request_id,
|
|
1275
|
+
)
|
|
1276
|
+
|
|
1277
|
+
return {
|
|
1278
|
+
"event": "proxmox_service_started",
|
|
1279
|
+
"success": True,
|
|
1280
|
+
"message": "Portacode service install completed",
|
|
1281
|
+
"ctid": str(vmid),
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
class StartProxmoxContainerHandler(SyncHandler):
|
|
1286
|
+
"""Start a managed container via the Proxmox API."""
|
|
1287
|
+
|
|
1288
|
+
@property
|
|
1289
|
+
def command_name(self) -> str:
|
|
1290
|
+
return "start_proxmox_container"
|
|
1291
|
+
|
|
1292
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1293
|
+
vmid = _parse_ctid(message)
|
|
1294
|
+
config = _ensure_infra_configured()
|
|
1295
|
+
proxmox = _connect_proxmox(config)
|
|
1296
|
+
node = _get_node_from_config(config)
|
|
1297
|
+
_ensure_container_managed(proxmox, node, vmid)
|
|
1298
|
+
|
|
1299
|
+
status, elapsed = _start_container(proxmox, node, vmid)
|
|
1300
|
+
_update_container_record(vmid, {"status": "running"})
|
|
1301
|
+
|
|
1302
|
+
infra = get_infra_snapshot()
|
|
1303
|
+
return {
|
|
1304
|
+
"event": "proxmox_container_action",
|
|
1305
|
+
"action": "start",
|
|
1306
|
+
"success": True,
|
|
1307
|
+
"ctid": str(vmid),
|
|
1308
|
+
"message": f"Started container {vmid} in {elapsed:.1f}s.",
|
|
1309
|
+
"details": {"exitstatus": status.get("exitstatus")},
|
|
1310
|
+
"status": status.get("status"),
|
|
1311
|
+
"infra": infra,
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
class StopProxmoxContainerHandler(SyncHandler):
|
|
1316
|
+
"""Stop a managed container via the Proxmox API."""
|
|
1317
|
+
|
|
1318
|
+
@property
|
|
1319
|
+
def command_name(self) -> str:
|
|
1320
|
+
return "stop_proxmox_container"
|
|
1321
|
+
|
|
1322
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1323
|
+
vmid = _parse_ctid(message)
|
|
1324
|
+
config = _ensure_infra_configured()
|
|
1325
|
+
proxmox = _connect_proxmox(config)
|
|
1326
|
+
node = _get_node_from_config(config)
|
|
1327
|
+
_ensure_container_managed(proxmox, node, vmid)
|
|
1328
|
+
|
|
1329
|
+
status, elapsed = _stop_container(proxmox, node, vmid)
|
|
1330
|
+
final_status = status.get("status") or "stopped"
|
|
1331
|
+
_update_container_record(vmid, {"status": final_status})
|
|
1332
|
+
|
|
1333
|
+
infra = get_infra_snapshot()
|
|
1334
|
+
message_text = (
|
|
1335
|
+
f"Container {vmid} is already stopped."
|
|
1336
|
+
if final_status != "running" and elapsed == 0.0
|
|
1337
|
+
else f"Stopped container {vmid} in {elapsed:.1f}s."
|
|
1338
|
+
)
|
|
1339
|
+
return {
|
|
1340
|
+
"event": "proxmox_container_action",
|
|
1341
|
+
"action": "stop",
|
|
1342
|
+
"success": True,
|
|
1343
|
+
"ctid": str(vmid),
|
|
1344
|
+
"message": message_text,
|
|
1345
|
+
"details": {"exitstatus": status.get("exitstatus")},
|
|
1346
|
+
"status": final_status,
|
|
1347
|
+
"infra": infra,
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
class RemoveProxmoxContainerHandler(SyncHandler):
|
|
1352
|
+
"""Delete a managed container via the Proxmox API."""
|
|
1353
|
+
|
|
1354
|
+
@property
|
|
1355
|
+
def command_name(self) -> str:
|
|
1356
|
+
return "remove_proxmox_container"
|
|
1357
|
+
|
|
1358
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1359
|
+
vmid = _parse_ctid(message)
|
|
1360
|
+
config = _ensure_infra_configured()
|
|
1361
|
+
proxmox = _connect_proxmox(config)
|
|
1362
|
+
node = _get_node_from_config(config)
|
|
1363
|
+
_ensure_container_managed(proxmox, node, vmid)
|
|
1364
|
+
|
|
1365
|
+
stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
|
|
1366
|
+
delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
|
|
1367
|
+
_remove_container_record(vmid)
|
|
1368
|
+
|
|
1369
|
+
infra = get_infra_snapshot()
|
|
1370
|
+
return {
|
|
1371
|
+
"event": "proxmox_container_action",
|
|
1372
|
+
"action": "remove",
|
|
1373
|
+
"success": True,
|
|
1374
|
+
"ctid": str(vmid),
|
|
1375
|
+
"message": f"Deleted container {vmid} in {delete_elapsed:.1f}s.",
|
|
1376
|
+
"details": {
|
|
1377
|
+
"stop_exitstatus": stop_status.get("exitstatus"),
|
|
1378
|
+
"delete_exitstatus": delete_status.get("exitstatus"),
|
|
1379
|
+
},
|
|
1380
|
+
"status": "deleted",
|
|
1381
|
+
"infra": infra,
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
|
|
758
1385
|
class ConfigureProxmoxInfraHandler(SyncHandler):
|
|
759
1386
|
@property
|
|
760
1387
|
def command_name(self) -> str:
|