portacode 1.4.11.dev1__py3-none-any.whl → 1.4.11.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
portacode/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.4.11.dev1'
32
- __version_tuple__ = version_tuple = (1, 4, 11, 'dev1')
31
+ __version__ = version = '1.4.11.dev2'
32
+ __version_tuple__ = version_tuple = (1, 4, 11, 'dev2')
33
33
 
34
34
  __commit_id__ = commit_id = None
portacode/cli.py CHANGED
@@ -405,6 +405,45 @@ def send_control(message: str, gateway: str | None) -> None: # noqa: D401 – C
405
405
  finally:
406
406
  await mgr.stop()
407
407
 
408
+ asyncio.run(_run())
409
+
410
+
411
+ @cli.command("revert_proxmox_infra")
412
+ @click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
413
+ def revert_proxmox_infra(gateway: str | None) -> None: # noqa: D401 – Click callback
414
+ """Revert the Proxmox infrastructure configuration stored on this device."""
415
+
416
+ target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
417
+
418
+ async def _run() -> None:
419
+ keypair = get_or_create_keypair()
420
+ mgr = ConnectionManager(target_gateway, keypair, debug=False)
421
+ await mgr.start()
422
+
423
+ for _ in range(20):
424
+ if mgr.mux is not None:
425
+ break
426
+ await asyncio.sleep(0.1)
427
+ if mgr.mux is None:
428
+ click.echo("Failed to initialise connection – aborting.")
429
+ await mgr.stop()
430
+ return
431
+
432
+ ctl = mgr.mux.get_channel(0)
433
+ await ctl.send({"cmd": "revert_proxmox_infra"})
434
+
435
+ try:
436
+ with click.progressbar(length=30, label="Waiting for revert response") as bar:
437
+ for _ in range(30):
438
+ try:
439
+ reply = await asyncio.wait_for(ctl.recv(), timeout=0.1)
440
+ click.echo(click.style("< " + json.dumps(reply, indent=2), fg="cyan"))
441
+ except asyncio.TimeoutError:
442
+ pass
443
+ bar.update(1)
444
+ finally:
445
+ await mgr.stop()
446
+
408
447
  asyncio.run(_run())
409
448
 
410
449
 
@@ -347,6 +347,39 @@ This action does not require any payload fields.
347
347
 
348
348
  * On success, the device will emit a [`proxmox_infra_reverted`](#proxmox_infra_reverted-event) event containing the cleared snapshot.
349
349
 
350
+ ### `create_proxmox_container`
351
+
352
+ Creates a Portacode-managed LXC container, starts it, and bootstraps the Portacode service by running the commands from [`proxmox_management/setup_portacode.py`](../../proxmox_management/setup_portacode.py). Handled by [`CreateProxmoxContainerHandler`](./proxmox_infra.py).
353
+
354
+ **Payload Fields:**
355
+
356
+ * `template` (string, required): Template identifier to use for the CT (e.g., `local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst`).
357
+ * `disk_gib` (integer, optional): Rootfs size in GiB (defaults to 32).
358
+ * `ram_mib` (integer, optional): Memory size in MiB (defaults to 2048).
359
+ * `cpus` (integer, optional): Number of CPU cores (defaults to 1).
360
+ * `hostname` (string, optional): Desired hostname inside the container; defaults to `ct<vmid>`.
361
+ * `username` (string, optional): OS user to provision (defaults to `svcuser`).
362
+ * `password` (string, optional): Password for the user (used only during provisioning).
363
+ * `ssh_key` (string, optional): SSH public key to add to the user.
364
+
365
+ **Responses:**
366
+
367
+ * On success, the device will emit a [`proxmox_container_created`](#proxmox_container_created-event) event that includes the Portacode auth key produced inside the container.
368
+ * On failure, the device will emit an [`error`](#error) event.
369
+
370
+ ### `proxmox_container_created`
371
+
372
+ Emitted after a successful `create_proxmox_container` action. Contains the new container ID, the Portacode public key produced inside the container, and the bootstrap logs.
373
+
374
+ **Event Fields:**
375
+
376
+ * `success` (boolean): True when the CT and Portacode bootstrap succeed.
377
+ * `message` (string): Human-readable summary (e.g., `Container 103 is ready`).
378
+ * `ctid` (string): The numeric CT ID.
379
+ * `public_key` (string): Portacode public auth key created inside the new container.
380
+ * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
381
+ * `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
382
+
350
383
  ### `clock_sync_request`
351
384
 
352
385
  Internal event that devices send to the gateway to request the authoritative server timestamp (used for adjusting `portacode.utils.ntp_clock`). The gateway responds immediately with [`clock_sync_response`](#clock_sync_response).
@@ -1029,6 +1062,19 @@ Emitted after a successful `revert_proxmox_infra` action. Indicates the infra co
1029
1062
  * `message` (string): Summary (e.g., "Proxmox infrastructure configuration reverted").
1030
1063
  * `infra` (object): Snapshot with `configured=false` (matching [`system_info`](#system_info-event) `proxmox.infra`).
1031
1064
 
1065
+ ### `proxmox_container_created`
1066
+
1067
+ Emitted after a successful `create_proxmox_container` action to report the newly created CT, its Portacode public key, and the bootstrap logs.
1068
+
1069
+ **Event Fields:**
1070
+
1071
+ * `success` (boolean): True when the CT provisioning and `portacode connect` steps complete.
1072
+ * `message` (string): Human-readable summary (e.g., `Container 102 is ready`).
1073
+ * `ctid` (string): The container ID that was created.
1074
+ * `public_key` (string): Portacode public auth key discovered inside the container.
1075
+ * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
1076
+ * `setup_steps` (array[object]): Detailed bootstrap step reports including stdout/stderr, elapsed time, and pass/fail status.
1077
+
1032
1078
  ### <a name="clock_sync_response"></a>`clock_sync_response`
1033
1079
 
1034
1080
  Reply sent by the gateway immediately after receiving a `clock_sync_request`. Devices use this event plus the measured round-trip time to keep their local `ntp_clock` offset accurate.
@@ -40,7 +40,11 @@ from .project_state_handlers import (
40
40
  ProjectStateGitRevertHandler,
41
41
  ProjectStateGitCommitHandler,
42
42
  )
43
- from .proxmox_infra import ConfigureProxmoxInfraHandler, RevertProxmoxInfraHandler
43
+ from .proxmox_infra import (
44
+ ConfigureProxmoxInfraHandler,
45
+ CreateProxmoxContainerHandler,
46
+ RevertProxmoxInfraHandler,
47
+ )
44
48
 
45
49
  __all__ = [
46
50
  "BaseHandler",
@@ -53,6 +57,7 @@ __all__ = [
53
57
  "TerminalListHandler",
54
58
  "SystemInfoHandler",
55
59
  "ConfigureProxmoxInfraHandler",
60
+ "CreateProxmoxContainerHandler",
56
61
  # File operation handlers (optional - register as needed)
57
62
  "FileReadHandler",
58
63
  "FileWriteHandler",
@@ -12,7 +12,7 @@ import sys
12
12
  import time
13
13
  from datetime import datetime
14
14
  from pathlib import Path
15
- from typing import Any, Dict, Iterable, List, Tuple
15
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
16
16
 
17
17
  import platformdirs
18
18
 
@@ -24,6 +24,8 @@ CONFIG_DIR = Path(platformdirs.user_config_dir("portacode"))
24
24
  CONFIG_PATH = CONFIG_DIR / "proxmox_infra.json"
25
25
  REPO_ROOT = Path(__file__).resolve().parents[3]
26
26
  NET_SETUP_SCRIPT = REPO_ROOT / "proxmox_management" / "net_setup.py"
27
+ CONTAINERS_DIR = CONFIG_DIR / "containers"
28
+ MANAGED_MARKER = "portacode-managed:true"
27
29
 
28
30
  DEFAULT_HOST = "localhost"
29
31
  DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
@@ -233,6 +235,327 @@ def _revert_bridge() -> None:
233
235
  logger.warning("Proxmox bridge revert failed: %s", exc)
234
236
 
235
237
 
238
+ def _ensure_containers_dir() -> None:
239
+ CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
240
+
241
+
242
+ def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
243
+ if storage_type in ("lvm", "lvmthin"):
244
+ return f"{storage}:{disk_gib}"
245
+ return f"{storage}:{disk_gib}G"
246
+
247
+
248
+ def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
249
+ for entry in storages:
250
+ if entry.get("storage") == storage_name:
251
+ return entry.get("type", "")
252
+ return ""
253
+
254
+
255
+ def _validate_positive_int(value: Any, default: int) -> int:
256
+ try:
257
+ candidate = int(value)
258
+ if candidate > 0:
259
+ return candidate
260
+ except Exception:
261
+ pass
262
+ return default
263
+
264
+
265
+ def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
266
+ start = time.time()
267
+ while True:
268
+ status = proxmox.nodes(node).tasks(upid).status.get()
269
+ if status.get("status") == "stopped":
270
+ return status, time.time() - start
271
+ time.sleep(1)
272
+
273
+
274
+ def _list_running_managed(proxmox: Any, node: str) -> List[Tuple[str, Dict[str, Any]]]:
275
+ entries = []
276
+ for ct in proxmox.nodes(node).lxc.get():
277
+ if ct.get("status") != "running":
278
+ continue
279
+ vmid = str(ct.get("vmid"))
280
+ cfg = proxmox.nodes(node).lxc(vmid).config.get()
281
+ if cfg and MANAGED_MARKER in (cfg.get("description") or ""):
282
+ entries.append((vmid, cfg))
283
+ return entries
284
+
285
+
286
+ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
287
+ status = proxmox.nodes(node).lxc(vmid).status.current.get()
288
+ if status.get("status") == "running":
289
+ uptime = status.get("uptime", 0)
290
+ logger.info("Container %s already running (%ss)", vmid, uptime)
291
+ return status, 0.0
292
+
293
+ node_status = proxmox.nodes(node).status.get()
294
+ mem_total_mb = int(node_status.get("memory", {}).get("total", 0) // (1024**2))
295
+ cores_total = int(node_status.get("cpuinfo", {}).get("cores", 0))
296
+
297
+ running = _list_running_managed(proxmox, node)
298
+ used_mem_mb = sum(int(cfg.get("memory", 0)) for _, cfg in running)
299
+ used_cores = sum(int(cfg.get("cores", 0)) for _, cfg in running)
300
+
301
+ target_cfg = proxmox.nodes(node).lxc(vmid).config.get()
302
+ target_mem_mb = int(target_cfg.get("memory", 0))
303
+ target_cores = int(target_cfg.get("cores", 0))
304
+
305
+ if mem_total_mb and used_mem_mb + target_mem_mb > mem_total_mb:
306
+ raise RuntimeError("Not enough RAM to start this container safely.")
307
+ if cores_total and used_cores + target_cores > cores_total:
308
+ raise RuntimeError("Not enough CPU cores to start this container safely.")
309
+
310
+ upid = proxmox.nodes(node).lxc(vmid).status.start.post()
311
+ return _wait_for_task(proxmox, node, upid)
312
+
313
+
314
+ def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
315
+ _ensure_containers_dir()
316
+ path = CONTAINERS_DIR / f"ct-{vmid}.json"
317
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
318
+
319
+
320
+ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
321
+ templates = config.get("templates") or []
322
+ default_template = templates[0] if templates else ""
323
+ template = message.get("template") or default_template
324
+ if not template:
325
+ raise ValueError("Container template is required.")
326
+
327
+ bridge = config.get("network", {}).get("bridge", DEFAULT_BRIDGE)
328
+ hostname = (message.get("hostname") or "").strip()
329
+ disk_gib = _validate_positive_int(message.get("disk_gib") or message.get("disk"), 32)
330
+ ram_mib = _validate_positive_int(message.get("ram_mib") or message.get("ram"), 2048)
331
+ cpus = _validate_positive_int(message.get("cpus"), 1)
332
+ storage = message.get("storage") or config.get("default_storage") or ""
333
+ if not storage:
334
+ raise ValueError("Storage pool could not be determined.")
335
+
336
+ user = (message.get("username") or "svcuser").strip() or "svcuser"
337
+ password = message.get("password") or ""
338
+ ssh_key = (message.get("ssh_key") or "").strip()
339
+
340
+ payload = {
341
+ "template": template,
342
+ "storage": storage,
343
+ "disk_gib": disk_gib,
344
+ "ram_mib": ram_mib,
345
+ "cpus": cpus,
346
+ "hostname": hostname,
347
+ "net0": f"name=eth0,bridge={bridge},ip=dhcp",
348
+ "unprivileged": 1,
349
+ "swap_mb": 0,
350
+ "username": user,
351
+ "password": password,
352
+ "ssh_public_key": ssh_key,
353
+ "description": MANAGED_MARKER,
354
+ }
355
+ return payload
356
+
357
+
358
+ def _connect_proxmox(config: Dict[str, Any]) -> Any:
359
+ ProxmoxAPI = _ensure_proxmoxer()
360
+ return ProxmoxAPI(
361
+ config.get("host", DEFAULT_HOST),
362
+ user=config.get("user"),
363
+ token_name=config.get("token_name"),
364
+ token_value=config.get("token_value"),
365
+ verify_ssl=config.get("verify_ssl", False),
366
+ timeout=60,
367
+ )
368
+
369
+
370
+ def _run_pct(vmid: int, cmd: str) -> Dict[str, Any]:
371
+ full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
372
+ start = time.time()
373
+ proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
374
+ return {
375
+ "cmd": cmd,
376
+ "returncode": proc.returncode,
377
+ "stdout": proc.stdout.strip(),
378
+ "stderr": proc.stderr.strip(),
379
+ "elapsed_s": round(time.time() - start, 2),
380
+ }
381
+
382
+
383
+ def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
384
+ res = _run_pct(vmid, cmd)
385
+ if res["returncode"] != 0:
386
+ raise RuntimeError(res.get("stderr") or res.get("stdout") or "command failed")
387
+ return res
388
+
389
+
390
+ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
391
+ cmd = ["pct", "exec", str(vmid), "--", "bash", "-lc", f"su - {user} -c 'portacode connect'"]
392
+ proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
393
+ start = time.time()
394
+
395
+ data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
396
+ data_dir = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
397
+ key_dir = f"{data_dir}/portacode/keys"
398
+ pub_path = f"{key_dir}/id_portacode.pub"
399
+ priv_path = f"{key_dir}/id_portacode"
400
+
401
+ def file_size(path: str) -> Optional[int]:
402
+ stat_cmd = f"su - {user} -c 'test -s {path} && stat -c %s {path}'"
403
+ res = _run_pct(vmid, stat_cmd)
404
+ if res["returncode"] != 0:
405
+ return None
406
+ try:
407
+ return int(res["stdout"].strip())
408
+ except ValueError:
409
+ return None
410
+
411
+ last_pub = last_priv = None
412
+ stable = 0
413
+ while time.time() - start < timeout_s:
414
+ if proc.poll() is not None:
415
+ out, err = proc.communicate(timeout=1)
416
+ return {
417
+ "ok": False,
418
+ "error": "portacode connect exited before keys were created",
419
+ "stdout": (out or "").strip(),
420
+ "stderr": (err or "").strip(),
421
+ }
422
+ pub_size = file_size(pub_path)
423
+ priv_size = file_size(priv_path)
424
+ if pub_size and priv_size:
425
+ if pub_size == last_pub and priv_size == last_priv:
426
+ stable += 1
427
+ else:
428
+ stable = 0
429
+ last_pub, last_priv = pub_size, priv_size
430
+ if stable >= 1:
431
+ break
432
+ time.sleep(1)
433
+
434
+ if stable < 1:
435
+ proc.terminate()
436
+ try:
437
+ proc.wait(timeout=3)
438
+ except subprocess.TimeoutExpired:
439
+ proc.kill()
440
+ out, err = proc.communicate(timeout=1)
441
+ return {
442
+ "ok": False,
443
+ "error": "timed out waiting for portacode key files",
444
+ "stdout": (out or "").strip(),
445
+ "stderr": (err or "").strip(),
446
+ }
447
+
448
+ proc.terminate()
449
+ try:
450
+ proc.wait(timeout=3)
451
+ except subprocess.TimeoutExpired:
452
+ proc.kill()
453
+
454
+ key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
455
+ return {
456
+ "ok": True,
457
+ "public_key": key_res["stdout"].strip(),
458
+ }
459
+
460
+
461
+ def _summarize_error(res: Dict[str, Any]) -> str:
462
+ text = f"{res.get('stdout','')}\n{res.get('stderr','')}"
463
+ if "No space left on device" in text:
464
+ return "Disk full inside container; increase rootfs or clean apt cache."
465
+ if "Unable to acquire the dpkg frontend lock" in text or "lock-frontend" in text:
466
+ return "Another apt/dpkg process is running; retry after it finishes."
467
+ if "Temporary failure resolving" in text or "Could not resolve" in text:
468
+ return "DNS/network resolution failed inside container."
469
+ if "Failed to fetch" in text:
470
+ return "Package repo fetch failed; check network and apt sources."
471
+ return "Command failed; see stdout/stderr for details."
472
+
473
+
474
+ def _run_setup_steps(vmid: int, steps: List[Dict[str, Any]], user: str) -> Tuple[List[Dict[str, Any]], bool]:
475
+ results: List[Dict[str, Any]] = []
476
+ for step in steps:
477
+ if step.get("type") == "portacode_connect":
478
+ res = _portacode_connect_and_read_key(vmid, user, timeout_s=step.get("timeout_s", 10))
479
+ res["name"] = step["name"]
480
+ results.append(res)
481
+ if not res.get("ok"):
482
+ return results, False
483
+ continue
484
+
485
+ attempts = 0
486
+ while True:
487
+ attempts += 1
488
+ res = _run_pct(vmid, step["cmd"])
489
+ res["name"] = step["name"]
490
+ res["attempt"] = attempts
491
+ if res["returncode"] != 0:
492
+ res["error_summary"] = _summarize_error(res)
493
+ results.append(res)
494
+ if res["returncode"] == 0:
495
+ break
496
+ retry_on = step.get("retry_on", [])
497
+ if attempts >= step.get("retries", 0) + 1:
498
+ return results, False
499
+ if any(tok in (res.get("stderr", "") + res.get("stdout", "")) for tok in retry_on):
500
+ time.sleep(step.get("retry_delay_s", 3))
501
+ continue
502
+ return results, False
503
+ return results, True
504
+
505
+
506
+ def _bootstrap_portacode(vmid: int, user: str, password: str, ssh_key: str) -> Tuple[str, List[Dict[str, Any]]]:
507
+ steps = [
508
+ {
509
+ "name": "apt_update",
510
+ "cmd": "apt-get update -y",
511
+ "retries": 4,
512
+ "retry_delay_s": 5,
513
+ "retry_on": [
514
+ "Temporary failure resolving",
515
+ "Could not resolve",
516
+ "Failed to fetch",
517
+ ],
518
+ },
519
+ {
520
+ "name": "install_deps",
521
+ "cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
522
+ "retries": 5,
523
+ "retry_delay_s": 5,
524
+ "retry_on": [
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)
550
+ if not ok:
551
+ raise RuntimeError("Portacode bootstrap steps failed.")
552
+ key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
553
+ public_key = key_step.get("public_key") if key_step else None
554
+ if not public_key:
555
+ raise RuntimeError("Portacode connect did not return a public key.")
556
+ return public_key, results
557
+
558
+
236
559
  def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
237
560
  network = config.get("network", {})
238
561
  base_network = {
@@ -326,6 +649,90 @@ def revert_infrastructure() -> Dict[str, Any]:
326
649
  return snapshot
327
650
 
328
651
 
652
+ def _allocate_vmid(proxmox: Any) -> int:
653
+ return int(proxmox.cluster.nextid.get())
654
+
655
+
656
+ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) -> Tuple[int, float]:
657
+ from proxmoxer.core import ResourceException
658
+
659
+ storage_type = _get_storage_type(proxmox.nodes(node).storage.get(), payload["storage"])
660
+ rootfs = _format_rootfs(payload["storage"], payload["disk_gib"], storage_type)
661
+ vmid = _allocate_vmid(proxmox)
662
+ if not payload.get("hostname"):
663
+ payload["hostname"] = f"ct{vmid}"
664
+ try:
665
+ upid = proxmox.nodes(node).lxc.create(
666
+ vmid=vmid,
667
+ hostname=payload["hostname"],
668
+ ostemplate=payload["template"],
669
+ rootfs=rootfs,
670
+ memory=int(payload["ram_mib"]),
671
+ swap=int(payload.get("swap_mb", 0)),
672
+ cores=int(payload.get("cpus", 1)),
673
+ cpuunits=int(payload.get("cpuunits", 256)),
674
+ net0=payload["net0"],
675
+ unprivileged=int(payload.get("unprivileged", 1)),
676
+ description=payload.get("description", MANAGED_MARKER),
677
+ password=payload.get("password") or None,
678
+ ssh_public_keys=payload.get("ssh_public_key") or None,
679
+ )
680
+ status, elapsed = _wait_for_task(proxmox, node, upid)
681
+ return vmid, elapsed
682
+ except ResourceException as exc:
683
+ raise RuntimeError(f"Failed to create container: {exc}") from exc
684
+
685
+
686
+ class CreateProxmoxContainerHandler(SyncHandler):
687
+ """Provision a new managed LXC container via the Proxmox API."""
688
+
689
+ @property
690
+ def command_name(self) -> str:
691
+ return "create_proxmox_container"
692
+
693
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
694
+ if os.geteuid() != 0:
695
+ raise PermissionError("Container creation requires root privileges.")
696
+ config = _load_config()
697
+ if not config or not config.get("token_value"):
698
+ raise ValueError("Proxmox infrastructure is not configured.")
699
+ if not config.get("network", {}).get("applied"):
700
+ raise RuntimeError("Proxmox bridge setup must be applied before creating containers.")
701
+
702
+ proxmox = _connect_proxmox(config)
703
+ node = config.get("node") or DEFAULT_NODE_NAME
704
+ payload = _build_container_payload(message, config)
705
+ payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
706
+ payload["memory"] = int(payload["ram_mib"])
707
+ payload["node"] = node
708
+
709
+ vmid, _ = _instantiate_container(proxmox, node, payload)
710
+ payload["vmid"] = vmid
711
+ payload["created_at"] = datetime.utcnow().isoformat() + "Z"
712
+ _write_container_record(vmid, payload)
713
+
714
+ _start_container(proxmox, node, vmid)
715
+ public_key, steps = _bootstrap_portacode(vmid, payload["username"], payload["password"], payload["ssh_public_key"])
716
+
717
+ return {
718
+ "event": "proxmox_container_created",
719
+ "success": True,
720
+ "message": f"Container {vmid} is ready and Portacode key captured.",
721
+ "ctid": str(vmid),
722
+ "public_key": public_key,
723
+ "container": {
724
+ "vmid": vmid,
725
+ "hostname": payload["hostname"],
726
+ "template": payload["template"],
727
+ "storage": payload["storage"],
728
+ "disk_gib": payload["disk_gib"],
729
+ "ram_mib": payload["ram_mib"],
730
+ "cpus": payload["cpus"],
731
+ },
732
+ "setup_steps": steps,
733
+ }
734
+
735
+
329
736
  class ConfigureProxmoxInfraHandler(SyncHandler):
330
737
  @property
331
738
  def command_name(self) -> str:
@@ -7,6 +7,7 @@ import logging
7
7
  import os
8
8
  import platform
9
9
  import shutil
10
+ import subprocess
10
11
  import threading
11
12
  from pathlib import Path
12
13
  from typing import Any, Dict
@@ -128,25 +129,57 @@ def _get_playwright_info() -> Dict[str, Any]:
128
129
  return result
129
130
 
130
131
 
131
- def _get_proxmox_info() -> Dict[str, Any]:
132
- """Detect if the current host is a Proxmox node."""
133
- info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
132
+ def _run_probe_command(cmd: list[str]) -> str | None:
133
+ try:
134
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=3)
135
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
136
+ return None
137
+ return result.stdout.strip()
138
+
139
+
140
+ def _parse_pveversion(output: str) -> str | None:
141
+ first_token = output.split(None, 1)[0] if output else ""
142
+ if not first_token:
143
+ return None
144
+ if "/" in first_token:
145
+ return first_token.split("/", 1)[1]
146
+ return first_token
147
+
148
+
149
+ def _parse_dpkg_version(output: str) -> str | None:
150
+ for line in output.splitlines():
151
+ if line.lower().startswith("version:"):
152
+ return line.split(":", 1)[1].strip()
153
+ return None
154
+
155
+
156
+ def _get_proxmox_version() -> str | None:
134
157
  release_file = Path("/etc/proxmox-release")
135
158
  if release_file.exists():
136
- info["is_proxmox_node"] = True
137
159
  try:
138
- info["version"] = release_file.read_text().strip()
160
+ return release_file.read_text().strip()
139
161
  except Exception:
140
- info["version"] = None
141
- elif Path("/etc/pve").exists():
162
+ pass
163
+ value = _run_probe_command(["pveversion"])
164
+ parsed = _parse_pveversion(value or "")
165
+ if parsed:
166
+ return parsed
167
+ for pkg in ("pve-manager", "proxmox-ve"):
168
+ pkg_output = _run_probe_command(["dpkg", "-s", pkg])
169
+ parsed = _parse_dpkg_version(pkg_output or "")
170
+ if parsed:
171
+ return parsed
172
+ return None
173
+
174
+
175
+ def _get_proxmox_info() -> Dict[str, Any]:
176
+ """Detect if the current host is a Proxmox node."""
177
+ info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
178
+ if Path("/etc/proxmox-release").exists() or Path("/etc/pve").exists():
142
179
  info["is_proxmox_node"] = True
143
- if not info["version"]:
144
- version_hint = Path("/etc/pve/.version")
145
- if version_hint.exists():
146
- try:
147
- info["version"] = version_hint.read_text().strip()
148
- except Exception:
149
- info["version"] = None
180
+ version = _get_proxmox_version()
181
+ if version:
182
+ info["version"] = version
150
183
  info["infra"] = get_infra_snapshot()
151
184
  return info
152
185
 
@@ -53,6 +53,7 @@ from .handlers import (
53
53
  ProjectStateGitCommitHandler,
54
54
  UpdatePortacodeHandler,
55
55
  ConfigureProxmoxInfraHandler,
56
+ CreateProxmoxContainerHandler,
56
57
  RevertProxmoxInfraHandler,
57
58
  )
58
59
  from .handlers.project_aware_file_handlers import (
@@ -475,6 +476,7 @@ class TerminalManager:
475
476
  self._command_registry.register(ProjectStateGitCommitHandler)
476
477
  # System management handlers
477
478
  self._command_registry.register(ConfigureProxmoxInfraHandler)
479
+ self._command_registry.register(CreateProxmoxContainerHandler)
478
480
  self._command_registry.register(RevertProxmoxInfraHandler)
479
481
  self._command_registry.register(UpdatePortacodeHandler)
480
482
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.11.dev1
3
+ Version: 1.4.11.dev2
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,8 +1,8 @@
1
1
  portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
2
2
  portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
3
3
  portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
4
- portacode/_version.py,sha256=wMbY7UAgf3P9gxbSNOxLFzuxQjDnRzo7obYiAsaXcC8,719
5
- portacode/cli.py,sha256=R6BkYM-o2MCUETk-o2h-U_24E96EbVmQpIXJJY2H08Y,20387
4
+ portacode/_version.py,sha256=DyyohhLO48-FVlZsoOf271qw5sfxzwugB2wwyNnnQyQ,719
5
+ portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
6
6
  portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
7
7
  portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
8
8
  portacode/logging_categories.py,sha256=9m-BYrjyHh1vjZYBQT4JhAh6b_oYUhIWayO-noH1cSE,5063
@@ -12,20 +12,20 @@ portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJ
12
12
  portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
13
13
  portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
14
14
  portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
15
- portacode/connection/terminal.py,sha256=p8Wc4PsRBgYSYGO8htIHw48YfXMg8aSDvwuePA3IHgw,44493
15
+ portacode/connection/terminal.py,sha256=NFkbbJe4Rz5XUi1WYEf9kXJqH8ETqqC9tcQhVORG3Y8,44599
16
16
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
17
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=R2gQaW3fRSCYrhWBdR4qJi5mosEvTpJyq2gOZY4yfAw,87717
18
- portacode/connection/handlers/__init__.py,sha256=AzssrCT2wzdbWNqVummZvQO7AzbUXqodNRlFh4ncPSk,2505
17
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=1cYtf0iJBtY_Q4dadmKSdcHd67zg1f2mf-Gnm4-oI08,90511
18
+ portacode/connection/handlers/__init__.py,sha256=ZyhqJDgU5t5p83sCIvAGpAPZ1ShHU2a40q-KYnq9Uf8,2590
19
19
  portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
20
20
  portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
21
21
  portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZbQw6sF04M3dM6rUV8Q,24477
22
22
  portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
23
23
  portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
24
24
  portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
25
- portacode/connection/handlers/proxmox_infra.py,sha256=DGPyTIZM-25F80-jDqr955dO8kWnOCyrLUC_fo-mTPI,12798
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=eNK905IoelaFDkeHu57RUMtQzSU26CNPtJXxqPwZ6NI,28452
26
26
  portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
27
27
  portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
28
- portacode/connection/handlers/system_handlers.py,sha256=n4c8WmWZCaWPfo-v2fX2d1s9vUKBfZ8HUiouXF9vjd0,9758
28
+ portacode/connection/handlers/system_handlers.py,sha256=KY5twX1_90hCCwk8RmbkORdjqGWmmAD2WanV4X0Zc1c,10720
29
29
  portacode/connection/handlers/tab_factory.py,sha256=yn93h6GASjD1VpvW1oqpax3EpoT0r7r97zFXxML1wdA,16173
30
30
  portacode/connection/handlers/terminal_handlers.py,sha256=HRwHW1GiqG1NtHVEqXHKaYkFfQEzCDDH6YIlHcb4XD8,11866
31
31
  portacode/connection/handlers/update_handler.py,sha256=f2K4LmG4sHJZ3LahzzoRtHBULTKkPUNwuyhwuAAg3RA,2054
@@ -64,7 +64,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
64
64
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
65
65
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
66
66
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
67
- portacode-1.4.11.dev1.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
67
+ portacode-1.4.11.dev2.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
68
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
69
69
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
70
70
  test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
@@ -90,8 +90,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
90
90
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
91
91
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
92
92
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
93
- portacode-1.4.11.dev1.dist-info/METADATA,sha256=yraL8iRnELQ6dGUyfeYovjX5NyAl9CyeuCYEogodGdw,13051
94
- portacode-1.4.11.dev1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
95
- portacode-1.4.11.dev1.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
96
- portacode-1.4.11.dev1.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
97
- portacode-1.4.11.dev1.dist-info/RECORD,,
93
+ portacode-1.4.11.dev2.dist-info/METADATA,sha256=L4EQ_bW0QXbeefqzzoRNOE7f2XEBfEiRS7wWJG82mTE,13051
94
+ portacode-1.4.11.dev2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
95
+ portacode-1.4.11.dev2.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
96
+ portacode-1.4.11.dev2.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
97
+ portacode-1.4.11.dev2.dist-info/RECORD,,