portacode 1.4.11.dev0__py3-none-any.whl → 1.4.12.dev1__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/cli.py +39 -0
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +212 -16
- portacode/connection/handlers/__init__.py +16 -2
- portacode/connection/handlers/proxmox_infra.py +1118 -8
- portacode/connection/handlers/system_handlers.py +48 -7
- portacode/connection/terminal.py +7 -0
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.12.dev1.dist-info}/METADATA +1 -1
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.12.dev1.dist-info}/RECORD +13 -13
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.12.dev1.dist-info}/WHEEL +0 -0
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.12.dev1.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.12.dev1.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.11.dev0.dist-info → portacode-1.4.12.dev1.dist-info}/top_level.txt +0 -0
|
@@ -2,16 +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
|
|
14
|
+
import time
|
|
15
|
+
import threading
|
|
12
16
|
from datetime import datetime
|
|
13
17
|
from pathlib import Path
|
|
14
|
-
from typing import Any, Dict, Iterable, List, Tuple
|
|
18
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
|
15
19
|
|
|
16
20
|
import platformdirs
|
|
17
21
|
|
|
@@ -21,6 +25,10 @@ logger = logging.getLogger(__name__)
|
|
|
21
25
|
|
|
22
26
|
CONFIG_DIR = Path(platformdirs.user_config_dir("portacode"))
|
|
23
27
|
CONFIG_PATH = CONFIG_DIR / "proxmox_infra.json"
|
|
28
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
29
|
+
NET_SETUP_SCRIPT = REPO_ROOT / "proxmox_management" / "net_setup.py"
|
|
30
|
+
CONTAINERS_DIR = CONFIG_DIR / "containers"
|
|
31
|
+
MANAGED_MARKER = "portacode-managed:true"
|
|
24
32
|
|
|
25
33
|
DEFAULT_HOST = "localhost"
|
|
26
34
|
DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
|
|
@@ -33,6 +41,58 @@ DNS_SERVER = "1.1.1.1"
|
|
|
33
41
|
IFACES_PATH = Path("/etc/network/interfaces")
|
|
34
42
|
SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
|
|
35
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
|
+
)
|
|
36
96
|
|
|
37
97
|
|
|
38
98
|
def _call_subprocess(cmd: List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
|
@@ -214,10 +274,593 @@ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
|
|
|
214
274
|
return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
|
|
215
275
|
|
|
216
276
|
|
|
277
|
+
def _verify_connectivity(timeout: float = 5.0) -> bool:
|
|
278
|
+
try:
|
|
279
|
+
_call_subprocess(["/bin/ping", "-c", "2", "1.1.1.1"], check=True, timeout=timeout)
|
|
280
|
+
return True
|
|
281
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _revert_bridge() -> None:
|
|
286
|
+
try:
|
|
287
|
+
if NET_SETUP_SCRIPT.exists():
|
|
288
|
+
_call_subprocess([sys.executable, str(NET_SETUP_SCRIPT), "revert"], check=True)
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
logger.warning("Proxmox bridge revert failed: %s", exc)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _ensure_containers_dir() -> None:
|
|
294
|
+
CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
|
|
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
|
+
|
|
408
|
+
def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
|
|
409
|
+
if storage_type in ("lvm", "lvmthin"):
|
|
410
|
+
return f"{storage}:{disk_gib}"
|
|
411
|
+
return f"{storage}:{disk_gib}G"
|
|
412
|
+
|
|
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
|
+
|
|
476
|
+
def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
|
|
477
|
+
for entry in storages:
|
|
478
|
+
if entry.get("storage") == storage_name:
|
|
479
|
+
return entry.get("type", "")
|
|
480
|
+
return ""
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _validate_positive_number(value: Any, default: float) -> float:
|
|
484
|
+
try:
|
|
485
|
+
candidate = float(value)
|
|
486
|
+
if candidate > 0:
|
|
487
|
+
return candidate
|
|
488
|
+
except Exception:
|
|
489
|
+
pass
|
|
490
|
+
return float(default)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
|
|
494
|
+
start = time.time()
|
|
495
|
+
while True:
|
|
496
|
+
status = proxmox.nodes(node).tasks(upid).status.get()
|
|
497
|
+
if status.get("status") == "stopped":
|
|
498
|
+
return status, time.time() - start
|
|
499
|
+
time.sleep(1)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _list_running_managed(proxmox: Any, node: str) -> List[Tuple[str, Dict[str, Any]]]:
|
|
503
|
+
entries = []
|
|
504
|
+
for ct in proxmox.nodes(node).lxc.get():
|
|
505
|
+
if ct.get("status") != "running":
|
|
506
|
+
continue
|
|
507
|
+
vmid = str(ct.get("vmid"))
|
|
508
|
+
cfg = proxmox.nodes(node).lxc(vmid).config.get()
|
|
509
|
+
if cfg and MANAGED_MARKER in (cfg.get("description") or ""):
|
|
510
|
+
entries.append((vmid, cfg))
|
|
511
|
+
return entries
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
|
|
515
|
+
status = proxmox.nodes(node).lxc(vmid).status.current.get()
|
|
516
|
+
if status.get("status") == "running":
|
|
517
|
+
uptime = status.get("uptime", 0)
|
|
518
|
+
logger.info("Container %s already running (%ss)", vmid, uptime)
|
|
519
|
+
return status, 0.0
|
|
520
|
+
|
|
521
|
+
node_status = proxmox.nodes(node).status.get()
|
|
522
|
+
mem_total_mb = int(node_status.get("memory", {}).get("total", 0) // (1024**2))
|
|
523
|
+
cores_total = int(node_status.get("cpuinfo", {}).get("cores", 0))
|
|
524
|
+
|
|
525
|
+
running = _list_running_managed(proxmox, node)
|
|
526
|
+
used_mem_mb = sum(int(cfg.get("memory", 0)) for _, cfg in running)
|
|
527
|
+
used_cores = sum(int(cfg.get("cores", 0)) for _, cfg in running)
|
|
528
|
+
|
|
529
|
+
target_cfg = proxmox.nodes(node).lxc(vmid).config.get()
|
|
530
|
+
target_mem_mb = int(target_cfg.get("memory", 0))
|
|
531
|
+
target_cores = int(target_cfg.get("cores", 0))
|
|
532
|
+
|
|
533
|
+
if mem_total_mb and used_mem_mb + target_mem_mb > mem_total_mb:
|
|
534
|
+
raise RuntimeError("Not enough RAM to start this container safely.")
|
|
535
|
+
if cores_total and used_cores + target_cores > cores_total:
|
|
536
|
+
raise RuntimeError("Not enough CPU cores to start this container safely.")
|
|
537
|
+
|
|
538
|
+
upid = proxmox.nodes(node).lxc(vmid).status.start.post()
|
|
539
|
+
return _wait_for_task(proxmox, node, upid)
|
|
540
|
+
|
|
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
|
+
|
|
555
|
+
def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
|
|
556
|
+
_ensure_containers_dir()
|
|
557
|
+
path = CONTAINERS_DIR / f"ct-{vmid}.json"
|
|
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()
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
|
|
583
|
+
templates = config.get("templates") or []
|
|
584
|
+
default_template = templates[0] if templates else ""
|
|
585
|
+
template = message.get("template") or default_template
|
|
586
|
+
if not template:
|
|
587
|
+
raise ValueError("Container template is required.")
|
|
588
|
+
|
|
589
|
+
bridge = config.get("network", {}).get("bridge", DEFAULT_BRIDGE)
|
|
590
|
+
hostname = (message.get("hostname") or "").strip()
|
|
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"), 1)
|
|
594
|
+
storage = message.get("storage") or config.get("default_storage") or ""
|
|
595
|
+
if not storage:
|
|
596
|
+
raise ValueError("Storage pool could not be determined.")
|
|
597
|
+
|
|
598
|
+
user, password, ssh_key = _get_provisioning_user_info(message)
|
|
599
|
+
|
|
600
|
+
payload = {
|
|
601
|
+
"template": template,
|
|
602
|
+
"storage": storage,
|
|
603
|
+
"disk_gib": disk_gib,
|
|
604
|
+
"ram_mib": ram_mib,
|
|
605
|
+
"cpus": cpus,
|
|
606
|
+
"hostname": hostname,
|
|
607
|
+
"net0": f"name=eth0,bridge={bridge},ip=dhcp",
|
|
608
|
+
"unprivileged": 1,
|
|
609
|
+
"swap_mb": 0,
|
|
610
|
+
"username": user,
|
|
611
|
+
"password": password,
|
|
612
|
+
"ssh_public_key": ssh_key,
|
|
613
|
+
"description": MANAGED_MARKER,
|
|
614
|
+
}
|
|
615
|
+
return payload
|
|
616
|
+
|
|
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
|
+
|
|
650
|
+
def _connect_proxmox(config: Dict[str, Any]) -> Any:
|
|
651
|
+
ProxmoxAPI = _ensure_proxmoxer()
|
|
652
|
+
return ProxmoxAPI(
|
|
653
|
+
config.get("host", DEFAULT_HOST),
|
|
654
|
+
user=config.get("user"),
|
|
655
|
+
token_name=config.get("token_name"),
|
|
656
|
+
token_value=config.get("token_value"),
|
|
657
|
+
verify_ssl=config.get("verify_ssl", False),
|
|
658
|
+
timeout=60,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str, Any]:
|
|
663
|
+
full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
|
|
664
|
+
start = time.time()
|
|
665
|
+
proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, input=input_text)
|
|
666
|
+
return {
|
|
667
|
+
"cmd": cmd,
|
|
668
|
+
"returncode": proc.returncode,
|
|
669
|
+
"stdout": proc.stdout.strip(),
|
|
670
|
+
"stderr": proc.stderr.strip(),
|
|
671
|
+
"elapsed_s": round(time.time() - start, 2),
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
|
|
676
|
+
res = _run_pct(vmid, cmd)
|
|
677
|
+
if res["returncode"] != 0:
|
|
678
|
+
raise RuntimeError(res.get("stderr") or res.get("stdout") or "command failed")
|
|
679
|
+
return res
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
|
|
683
|
+
cmd = ["pct", "exec", str(vmid), "--", "bash", "-lc", f"su - {user} -c 'portacode connect'"]
|
|
684
|
+
proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
685
|
+
start = time.time()
|
|
686
|
+
|
|
687
|
+
data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
|
|
688
|
+
data_dir = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
|
|
689
|
+
key_dir = f"{data_dir}/portacode/keys"
|
|
690
|
+
pub_path = f"{key_dir}/id_portacode.pub"
|
|
691
|
+
priv_path = f"{key_dir}/id_portacode"
|
|
692
|
+
|
|
693
|
+
def file_size(path: str) -> Optional[int]:
|
|
694
|
+
stat_cmd = f"su - {user} -c 'test -s {path} && stat -c %s {path}'"
|
|
695
|
+
res = _run_pct(vmid, stat_cmd)
|
|
696
|
+
if res["returncode"] != 0:
|
|
697
|
+
return None
|
|
698
|
+
try:
|
|
699
|
+
return int(res["stdout"].strip())
|
|
700
|
+
except ValueError:
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
last_pub = last_priv = None
|
|
704
|
+
stable = 0
|
|
705
|
+
while time.time() - start < timeout_s:
|
|
706
|
+
if proc.poll() is not None:
|
|
707
|
+
out, err = proc.communicate(timeout=1)
|
|
708
|
+
return {
|
|
709
|
+
"ok": False,
|
|
710
|
+
"error": "portacode connect exited before keys were created",
|
|
711
|
+
"stdout": (out or "").strip(),
|
|
712
|
+
"stderr": (err or "").strip(),
|
|
713
|
+
}
|
|
714
|
+
pub_size = file_size(pub_path)
|
|
715
|
+
priv_size = file_size(priv_path)
|
|
716
|
+
if pub_size and priv_size:
|
|
717
|
+
if pub_size == last_pub and priv_size == last_priv:
|
|
718
|
+
stable += 1
|
|
719
|
+
else:
|
|
720
|
+
stable = 0
|
|
721
|
+
last_pub, last_priv = pub_size, priv_size
|
|
722
|
+
if stable >= 1:
|
|
723
|
+
break
|
|
724
|
+
time.sleep(1)
|
|
725
|
+
|
|
726
|
+
if stable < 1:
|
|
727
|
+
proc.terminate()
|
|
728
|
+
try:
|
|
729
|
+
proc.wait(timeout=3)
|
|
730
|
+
except subprocess.TimeoutExpired:
|
|
731
|
+
proc.kill()
|
|
732
|
+
out, err = proc.communicate(timeout=1)
|
|
733
|
+
return {
|
|
734
|
+
"ok": False,
|
|
735
|
+
"error": "timed out waiting for portacode key files",
|
|
736
|
+
"stdout": (out or "").strip(),
|
|
737
|
+
"stderr": (err or "").strip(),
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
proc.terminate()
|
|
741
|
+
try:
|
|
742
|
+
proc.wait(timeout=3)
|
|
743
|
+
except subprocess.TimeoutExpired:
|
|
744
|
+
proc.kill()
|
|
745
|
+
|
|
746
|
+
key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
|
|
747
|
+
return {
|
|
748
|
+
"ok": True,
|
|
749
|
+
"public_key": key_res["stdout"].strip(),
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _summarize_error(res: Dict[str, Any]) -> str:
|
|
754
|
+
text = f"{res.get('stdout','')}\n{res.get('stderr','')}"
|
|
755
|
+
if "No space left on device" in text:
|
|
756
|
+
return "Disk full inside container; increase rootfs or clean apt cache."
|
|
757
|
+
if "Unable to acquire the dpkg frontend lock" in text or "lock-frontend" in text:
|
|
758
|
+
return "Another apt/dpkg process is running; retry after it finishes."
|
|
759
|
+
if "Temporary failure resolving" in text or "Could not resolve" in text:
|
|
760
|
+
return "DNS/network resolution failed inside container."
|
|
761
|
+
if "Failed to fetch" in text:
|
|
762
|
+
return "Package repo fetch failed; check network and apt sources."
|
|
763
|
+
return "Command failed; see stdout/stderr for details."
|
|
764
|
+
|
|
765
|
+
|
|
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]:
|
|
774
|
+
results: List[Dict[str, Any]] = []
|
|
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
|
+
|
|
781
|
+
if step.get("type") == "portacode_connect":
|
|
782
|
+
res = _portacode_connect_and_read_key(vmid, user, timeout_s=step.get("timeout_s", 10))
|
|
783
|
+
res["name"] = step["name"]
|
|
784
|
+
results.append(res)
|
|
785
|
+
if not res.get("ok"):
|
|
786
|
+
if progress_callback:
|
|
787
|
+
progress_callback(step_index, computed_total, step, "failed", res)
|
|
788
|
+
return results, False
|
|
789
|
+
if progress_callback:
|
|
790
|
+
progress_callback(step_index, computed_total, step, "completed", res)
|
|
791
|
+
continue
|
|
792
|
+
|
|
793
|
+
attempts = 0
|
|
794
|
+
retry_on = step.get("retry_on", [])
|
|
795
|
+
max_attempts = step.get("retries", 0) + 1
|
|
796
|
+
while True:
|
|
797
|
+
attempts += 1
|
|
798
|
+
res = _run_pct(vmid, step["cmd"])
|
|
799
|
+
res["name"] = step["name"]
|
|
800
|
+
res["attempt"] = attempts
|
|
801
|
+
if res["returncode"] != 0:
|
|
802
|
+
res["error_summary"] = _summarize_error(res)
|
|
803
|
+
results.append(res)
|
|
804
|
+
if res["returncode"] == 0:
|
|
805
|
+
if progress_callback:
|
|
806
|
+
progress_callback(step_index, computed_total, step, "completed", res)
|
|
807
|
+
break
|
|
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:
|
|
820
|
+
time.sleep(step.get("retry_delay_s", 3))
|
|
821
|
+
continue
|
|
822
|
+
|
|
823
|
+
return results, False
|
|
824
|
+
return results, True
|
|
825
|
+
|
|
826
|
+
|
|
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
|
+
)
|
|
846
|
+
if not ok:
|
|
847
|
+
raise RuntimeError("Portacode bootstrap steps failed.")
|
|
848
|
+
key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
|
|
849
|
+
public_key = key_step.get("public_key") if key_step else None
|
|
850
|
+
if not public_key:
|
|
851
|
+
raise RuntimeError("Portacode connect did not return a public key.")
|
|
852
|
+
return public_key, results
|
|
853
|
+
|
|
854
|
+
|
|
217
855
|
def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
218
|
-
if not config:
|
|
219
|
-
return {"configured": False}
|
|
220
856
|
network = config.get("network", {})
|
|
857
|
+
base_network = {
|
|
858
|
+
"applied": network.get("applied", False),
|
|
859
|
+
"message": network.get("message"),
|
|
860
|
+
"bridge": network.get("bridge", DEFAULT_BRIDGE),
|
|
861
|
+
}
|
|
862
|
+
if not config:
|
|
863
|
+
return {"configured": False, "network": base_network}
|
|
221
864
|
return {
|
|
222
865
|
"configured": True,
|
|
223
866
|
"host": config.get("host"),
|
|
@@ -227,11 +870,7 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
227
870
|
"default_storage": config.get("default_storage"),
|
|
228
871
|
"templates": config.get("templates") or [],
|
|
229
872
|
"last_verified": config.get("last_verified"),
|
|
230
|
-
"network":
|
|
231
|
-
"applied": network.get("applied", False),
|
|
232
|
-
"message": network.get("message"),
|
|
233
|
-
"bridge": network.get("bridge", DEFAULT_BRIDGE),
|
|
234
|
-
},
|
|
873
|
+
"network": base_network,
|
|
235
874
|
}
|
|
236
875
|
|
|
237
876
|
|
|
@@ -254,6 +893,13 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
|
|
|
254
893
|
network: Dict[str, Any] = {}
|
|
255
894
|
try:
|
|
256
895
|
network = _ensure_bridge()
|
|
896
|
+
# Wait for network convergence before validating connectivity
|
|
897
|
+
time.sleep(2)
|
|
898
|
+
if _verify_connectivity():
|
|
899
|
+
network["health"] = "healthy"
|
|
900
|
+
else:
|
|
901
|
+
network = {"applied": False, "bridge": DEFAULT_BRIDGE, "message": "Connectivity check failed; bridge reverted"}
|
|
902
|
+
_revert_bridge()
|
|
257
903
|
except PermissionError as exc:
|
|
258
904
|
network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
|
|
259
905
|
logger.warning("Bridge setup skipped: %s", exc)
|
|
@@ -276,6 +922,7 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
|
|
|
276
922
|
_save_config(config)
|
|
277
923
|
snapshot = build_snapshot(config)
|
|
278
924
|
snapshot["node_status"] = status
|
|
925
|
+
snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
|
|
279
926
|
return snapshot
|
|
280
927
|
|
|
281
928
|
|
|
@@ -284,9 +931,457 @@ def get_infra_snapshot() -> Dict[str, Any]:
|
|
|
284
931
|
snapshot = build_snapshot(config)
|
|
285
932
|
if config.get("node_status"):
|
|
286
933
|
snapshot["node_status"] = config["node_status"]
|
|
934
|
+
snapshot["managed_containers"] = _get_managed_containers_summary()
|
|
935
|
+
return snapshot
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def revert_infrastructure() -> Dict[str, Any]:
|
|
939
|
+
_revert_bridge()
|
|
940
|
+
if CONFIG_PATH.exists():
|
|
941
|
+
CONFIG_PATH.unlink()
|
|
942
|
+
snapshot = build_snapshot({})
|
|
943
|
+
snapshot["network"] = snapshot.get("network", {})
|
|
944
|
+
snapshot["network"]["applied"] = False
|
|
945
|
+
snapshot["network"]["message"] = "Reverted to previous network state"
|
|
946
|
+
snapshot["network"]["bridge"] = DEFAULT_BRIDGE
|
|
947
|
+
snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
|
|
287
948
|
return snapshot
|
|
288
949
|
|
|
289
950
|
|
|
951
|
+
def _allocate_vmid(proxmox: Any) -> int:
|
|
952
|
+
return int(proxmox.cluster.nextid.get())
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) -> Tuple[int, float]:
|
|
956
|
+
from proxmoxer.core import ResourceException
|
|
957
|
+
|
|
958
|
+
storage_type = _get_storage_type(proxmox.nodes(node).storage.get(), payload["storage"])
|
|
959
|
+
rootfs = _format_rootfs(payload["storage"], payload["disk_gib"], storage_type)
|
|
960
|
+
vmid = _allocate_vmid(proxmox)
|
|
961
|
+
if not payload.get("hostname"):
|
|
962
|
+
payload["hostname"] = f"ct{vmid}"
|
|
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=int(payload.get("cpus", 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
|
+
)
|
|
979
|
+
status, elapsed = _wait_for_task(proxmox, node, upid)
|
|
980
|
+
return vmid, elapsed
|
|
981
|
+
except ResourceException as exc:
|
|
982
|
+
raise RuntimeError(f"Failed to create container: {exc}") from exc
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
class CreateProxmoxContainerHandler(SyncHandler):
|
|
986
|
+
"""Provision a new managed LXC container via the Proxmox API."""
|
|
987
|
+
|
|
988
|
+
@property
|
|
989
|
+
def command_name(self) -> str:
|
|
990
|
+
return "create_proxmox_container"
|
|
991
|
+
|
|
992
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
993
|
+
logger.info("create_proxmox_container command received")
|
|
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,
|
|
1065
|
+
)
|
|
1066
|
+
|
|
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
|
+
)
|
|
1082
|
+
vmid, _ = _instantiate_container(proxmox, node, payload)
|
|
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
|
+
)
|
|
1096
|
+
|
|
1097
|
+
def _start_container_step():
|
|
1098
|
+
_start_container(proxmox, node, vmid)
|
|
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)
|
|
1159
|
+
|
|
1160
|
+
return {
|
|
1161
|
+
"event": "proxmox_container_created",
|
|
1162
|
+
"success": True,
|
|
1163
|
+
"message": f"Container {vmid} is ready and Portacode key captured.",
|
|
1164
|
+
"ctid": str(vmid),
|
|
1165
|
+
"public_key": public_key,
|
|
1166
|
+
"container": {
|
|
1167
|
+
"vmid": vmid,
|
|
1168
|
+
"hostname": payload["hostname"],
|
|
1169
|
+
"template": payload["template"],
|
|
1170
|
+
"storage": payload["storage"],
|
|
1171
|
+
"disk_gib": payload["disk_gib"],
|
|
1172
|
+
"ram_mib": payload["ram_mib"],
|
|
1173
|
+
"cpus": payload["cpus"],
|
|
1174
|
+
},
|
|
1175
|
+
"setup_steps": steps,
|
|
1176
|
+
}
|
|
1177
|
+
|
|
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
|
+
|
|
290
1385
|
class ConfigureProxmoxInfraHandler(SyncHandler):
|
|
291
1386
|
@property
|
|
292
1387
|
def command_name(self) -> str:
|
|
@@ -305,3 +1400,18 @@ class ConfigureProxmoxInfraHandler(SyncHandler):
|
|
|
305
1400
|
"message": "Proxmox infrastructure configured",
|
|
306
1401
|
"infra": snapshot,
|
|
307
1402
|
}
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
class RevertProxmoxInfraHandler(SyncHandler):
|
|
1406
|
+
@property
|
|
1407
|
+
def command_name(self) -> str:
|
|
1408
|
+
return "revert_proxmox_infra"
|
|
1409
|
+
|
|
1410
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1411
|
+
snapshot = revert_infrastructure()
|
|
1412
|
+
return {
|
|
1413
|
+
"event": "proxmox_infra_reverted",
|
|
1414
|
+
"success": True,
|
|
1415
|
+
"message": "Proxmox infrastructure configuration reverted",
|
|
1416
|
+
"infra": snapshot,
|
|
1417
|
+
}
|