portacode 1.4.11.dev5__py3-none-any.whl → 1.4.11.dev7__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.dev5'
32
- __version_tuple__ = version_tuple = (1, 4, 11, 'dev5')
31
+ __version__ = version = '1.4.11.dev7'
32
+ __version_tuple__ = version_tuple = (1, 4, 11, 'dev7')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -380,6 +380,48 @@ Emitted after a successful `create_proxmox_container` action. Contains the new c
380
380
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
381
381
  * `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
382
382
 
383
+ ### `proxmox_container_progress`
384
+
385
+ Sent intermittently while `create_proxmox_container` is executing so callers can display a progress indicator. Each notification describes the currently running step (validation, provisioning, or each bootstrap command) and whether it succeeded or failed.
386
+
387
+ **Event Fields:**
388
+
389
+ * `step_index` (integer): 1-based index of the current step inside the entire provisioning sequence.
390
+ * `total_steps` (integer): Total number of steps that must run before provisioning completes.
391
+ * `step_name` (string): Internal step identifier (e.g., `create_container`, `apt_update`, `portacode_connect`).
392
+ * `step_label` (string): Human-friendly label suitable for UI (e.g., `Create container`, `Apt update`).
393
+ * `status` (string): One of `in_progress`, `completed`, or `failed`.
394
+ * `phase` (string): Either `lifecycle` (environment/container lifecycle) or `bootstrap` (per-command bootstrap work).
395
+ * `message` (string): Short description of what is happening or why a failure occurred.
396
+ * `details` (object, optional): Contains `attempt` (if retries were needed) and `error_summary` when a step fails.
397
+ * `request_id` (string, optional): Mirrors the request ID from the incoming `create_proxmox_container` payload when available.
398
+
399
+ ### `start_portacode_service`
400
+
401
+ Runs `sudo portacode service install` inside the container after the dashboard has created the corresponding Device record with the supplied public key.
402
+
403
+ **Payload Fields:**
404
+
405
+ * `ctid` (string, required): Container ID target.
406
+ * `step_index` (integer, required): Next step index to render inside `proxmox_container_progress`.
407
+ * `total_steps` (integer, required): The overall total number of steps (including lifecycle, bootstrap, and service installation).
408
+
409
+ **Responses:**
410
+
411
+ * Emits additional [`proxmox_container_progress`](#proxmox_container_progress-event) events to report the authentication and service-install steps.
412
+ * On success, emits a [`proxmox_service_started`](#proxmox_service_started-event).
413
+ * On failure, emits a generic [`error`](#error) event.
414
+
415
+ ### `proxmox_service_started`
416
+
417
+ Indicates that `portacode service install` finished successfully inside a managed container.
418
+
419
+ **Event Fields:**
420
+
421
+ * `success` (boolean): True when the install succeeded.
422
+ * `message` (string): Success summary (e.g., `Portacode service install completed`).
423
+ * `ctid` (string): Container ID.
424
+
383
425
  ### `clock_sync_request`
384
426
 
385
427
  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).
@@ -1075,6 +1117,22 @@ Emitted after a successful `create_proxmox_container` action to report the newly
1075
1117
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
1076
1118
  * `setup_steps` (array[object]): Detailed bootstrap step reports including stdout/stderr, elapsed time, and pass/fail status.
1077
1119
 
1120
+ ### `proxmox_container_progress`
1121
+
1122
+ Sent continuously while `create_proxmox_container` runs so dashboards can show a progress bar tied to each lifecycle and bootstrap step.
1123
+
1124
+ **Event Fields:**
1125
+
1126
+ * `step_index` (integer): 1-based position of the step inside the entire provisioning workflow.
1127
+ * `total_steps` (integer): Total number of lifecycle and bootstrap steps for the current operation.
1128
+ * `step_name` (string): Internal identifier (e.g., `validate_environment`, `install_deps`, `portacode_connect`).
1129
+ * `step_label` (string): Friendly label suitable for the UI.
1130
+ * `status` (string): One of `in_progress`, `completed`, or `failed`.
1131
+ * `phase` (string): Either `lifecycle` (node validation/container lifecycle) or `bootstrap` (commands run inside the CT).
1132
+ * `message` (string): Short human-readable description of the action or failure.
1133
+ * `details` (object, optional): Contains `attempt` (when retries are used) and `error_summary` on failure.
1134
+ * `request_id` (string, optional): Mirrors the `create_proxmox_container` request when provided.
1135
+
1078
1136
  ### <a name="clock_sync_response"></a>`clock_sync_response`
1079
1137
 
1080
1138
  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.
@@ -45,6 +45,7 @@ from .proxmox_infra import (
45
45
  ConfigureProxmoxInfraHandler,
46
46
  CreateProxmoxContainerHandler,
47
47
  RevertProxmoxInfraHandler,
48
+ StartPortacodeServiceHandler,
48
49
  )
49
50
 
50
51
  __all__ = [
@@ -84,6 +85,7 @@ __all__ = [
84
85
  "ProjectStateGitUnstageHandler",
85
86
  "ProjectStateGitRevertHandler",
86
87
  "ProjectStateGitCommitHandler",
88
+ "StartPortacodeServiceHandler",
87
89
  "UpdatePortacodeHandler",
88
90
  "RevertProxmoxInfraHandler",
89
91
  ]
@@ -2,9 +2,11 @@
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
@@ -12,7 +14,7 @@ import sys
12
14
  import time
13
15
  from datetime import datetime
14
16
  from pathlib import Path
15
- from typing import Any, Dict, Iterable, List, Optional, Tuple
17
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
16
18
 
17
19
  import platformdirs
18
20
 
@@ -39,6 +41,55 @@ IFACES_PATH = Path("/etc/network/interfaces")
39
41
  SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
40
42
  UNIT_DIR = Path("/etc/systemd/system")
41
43
 
44
+ ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
45
+
46
+
47
+ def _emit_progress_event(
48
+ handler: SyncHandler,
49
+ *,
50
+ step_index: int,
51
+ total_steps: int,
52
+ step_name: str,
53
+ step_label: str,
54
+ status: str,
55
+ message: str,
56
+ phase: str,
57
+ request_id: Optional[str],
58
+ details: Optional[Dict[str, Any]] = None,
59
+ ) -> None:
60
+ loop = handler.context.get("event_loop")
61
+ if not loop or loop.is_closed():
62
+ logger.debug(
63
+ "progress event skipped (no event loop) step=%s status=%s",
64
+ step_name,
65
+ status,
66
+ )
67
+ return
68
+
69
+ payload: Dict[str, Any] = {
70
+ "event": "proxmox_container_progress",
71
+ "step_name": step_name,
72
+ "step_label": step_label,
73
+ "status": status,
74
+ "phase": phase,
75
+ "step_index": step_index,
76
+ "total_steps": total_steps,
77
+ "message": message,
78
+ }
79
+ if request_id:
80
+ payload["request_id"] = request_id
81
+ if details:
82
+ payload["details"] = details
83
+
84
+ future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
85
+ future.add_done_callback(
86
+ lambda fut: logger.warning(
87
+ "Failed to emit progress event for %s: %s", step_name, fut.exception()
88
+ )
89
+ if fut.exception()
90
+ else None
91
+ )
92
+
42
93
 
43
94
  def _call_subprocess(cmd: List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
44
95
  env = os.environ.copy()
@@ -245,6 +296,68 @@ def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
245
296
  return f"{storage}:{disk_gib}G"
246
297
 
247
298
 
299
+ def _get_provisioning_user_info(message: Dict[str, Any]) -> Tuple[str, str, str]:
300
+ user = (message.get("username") or "svcuser").strip() if message else "svcuser"
301
+ user = user or "svcuser"
302
+ password = message.get("password")
303
+ if not password:
304
+ password = secrets.token_urlsafe(10)
305
+ ssh_key = (message.get("ssh_key") or "").strip() if message else ""
306
+ return user, password, ssh_key
307
+
308
+
309
+ def _friendly_step_label(step_name: str) -> str:
310
+ if not step_name:
311
+ return "Step"
312
+ normalized = step_name.replace("_", " ").strip()
313
+ return normalized.capitalize()
314
+
315
+
316
+ def _build_bootstrap_steps(user: str, password: str, ssh_key: str) -> List[Dict[str, Any]]:
317
+ steps = [
318
+ {
319
+ "name": "apt_update",
320
+ "cmd": "apt-get update -y",
321
+ "retries": 4,
322
+ "retry_delay_s": 5,
323
+ "retry_on": [
324
+ "Temporary failure resolving",
325
+ "Could not resolve",
326
+ "Failed to fetch",
327
+ ],
328
+ },
329
+ {
330
+ "name": "install_deps",
331
+ "cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
332
+ "retries": 5,
333
+ "retry_delay_s": 5,
334
+ "retry_on": [
335
+ "lock-frontend",
336
+ "Unable to acquire the dpkg frontend lock",
337
+ "Temporary failure resolving",
338
+ "Could not resolve",
339
+ "Failed to fetch",
340
+ ],
341
+ },
342
+ {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
343
+ {"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
344
+ ]
345
+ if password:
346
+ steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
347
+ if ssh_key:
348
+ steps.append({
349
+ "name": "add_ssh_key",
350
+ "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
351
+ "retries": 0,
352
+ })
353
+ steps.extend([
354
+ {"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
355
+ {"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
356
+ {"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30},
357
+ ])
358
+ return steps
359
+
360
+
248
361
  def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
249
362
  for entry in storages:
250
363
  if entry.get("storage") == storage_name:
@@ -317,6 +430,13 @@ def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
317
430
  path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
318
431
 
319
432
 
433
+ def _read_container_record(vmid: int) -> Dict[str, Any]:
434
+ path = CONTAINERS_DIR / f"ct-{vmid}.json"
435
+ if not path.exists():
436
+ raise FileNotFoundError(f"Container record {path} missing")
437
+ return json.loads(path.read_text(encoding="utf-8"))
438
+
439
+
320
440
  def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
321
441
  templates = config.get("templates") or []
322
442
  default_template = templates[0] if templates else ""
@@ -333,9 +453,7 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
333
453
  if not storage:
334
454
  raise ValueError("Storage pool could not be determined.")
335
455
 
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()
456
+ user, password, ssh_key = _get_provisioning_user_info(message)
339
457
 
340
458
  payload = {
341
459
  "template": template,
@@ -367,10 +485,10 @@ def _connect_proxmox(config: Dict[str, Any]) -> Any:
367
485
  )
368
486
 
369
487
 
370
- def _run_pct(vmid: int, cmd: str) -> Dict[str, Any]:
488
+ def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str, Any]:
371
489
  full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
372
490
  start = time.time()
373
- proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
491
+ proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, input=input_text)
374
492
  return {
375
493
  "cmd": cmd,
376
494
  "returncode": proc.returncode,
@@ -471,18 +589,36 @@ def _summarize_error(res: Dict[str, Any]) -> str:
471
589
  return "Command failed; see stdout/stderr for details."
472
590
 
473
591
 
474
- def _run_setup_steps(vmid: int, steps: List[Dict[str, Any]], user: str) -> Tuple[List[Dict[str, Any]], bool]:
592
+ def _run_setup_steps(
593
+ vmid: int,
594
+ steps: List[Dict[str, Any]],
595
+ user: str,
596
+ progress_callback: Optional[ProgressCallback] = None,
597
+ start_index: int = 1,
598
+ total_steps: Optional[int] = None,
599
+ ) -> Tuple[List[Dict[str, Any]], bool]:
475
600
  results: List[Dict[str, Any]] = []
476
- for step in steps:
601
+ computed_total = total_steps if total_steps is not None else start_index + len(steps) - 1
602
+ for offset, step in enumerate(steps):
603
+ step_index = start_index + offset
604
+ if progress_callback:
605
+ progress_callback(step_index, computed_total, step, "in_progress", None)
606
+
477
607
  if step.get("type") == "portacode_connect":
478
608
  res = _portacode_connect_and_read_key(vmid, user, timeout_s=step.get("timeout_s", 10))
479
609
  res["name"] = step["name"]
480
610
  results.append(res)
481
611
  if not res.get("ok"):
612
+ if progress_callback:
613
+ progress_callback(step_index, computed_total, step, "failed", res)
482
614
  return results, False
615
+ if progress_callback:
616
+ progress_callback(step_index, computed_total, step, "completed", res)
483
617
  continue
484
618
 
485
619
  attempts = 0
620
+ retry_on = step.get("retry_on", [])
621
+ max_attempts = step.get("retries", 0) + 1
486
622
  while True:
487
623
  attempts += 1
488
624
  res = _run_pct(vmid, step["cmd"])
@@ -492,61 +628,47 @@ def _run_setup_steps(vmid: int, steps: List[Dict[str, Any]], user: str) -> Tuple
492
628
  res["error_summary"] = _summarize_error(res)
493
629
  results.append(res)
494
630
  if res["returncode"] == 0:
631
+ if progress_callback:
632
+ progress_callback(step_index, computed_total, step, "completed", res)
495
633
  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):
634
+
635
+ will_retry = False
636
+ if attempts < max_attempts and retry_on:
637
+ stderr_stdout = (res.get("stderr", "") + res.get("stdout", ""))
638
+ if any(tok in stderr_stdout for tok in retry_on):
639
+ will_retry = True
640
+
641
+ if progress_callback:
642
+ status = "retrying" if will_retry else "failed"
643
+ progress_callback(step_index, computed_total, step, status, res)
644
+
645
+ if will_retry:
500
646
  time.sleep(step.get("retry_delay_s", 3))
501
647
  continue
648
+
502
649
  return results, False
503
650
  return results, True
504
651
 
505
652
 
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)
653
+ def _bootstrap_portacode(
654
+ vmid: int,
655
+ user: str,
656
+ password: str,
657
+ ssh_key: str,
658
+ steps: Optional[List[Dict[str, Any]]] = None,
659
+ progress_callback: Optional[ProgressCallback] = None,
660
+ start_index: int = 1,
661
+ total_steps: Optional[int] = None,
662
+ ) -> Tuple[str, List[Dict[str, Any]]]:
663
+ actual_steps = steps if steps is not None else _build_bootstrap_steps(user, password, ssh_key)
664
+ results, ok = _run_setup_steps(
665
+ vmid,
666
+ actual_steps,
667
+ user,
668
+ progress_callback=progress_callback,
669
+ start_index=start_index,
670
+ total_steps=total_steps,
671
+ )
550
672
  if not ok:
551
673
  raise RuntimeError("Portacode bootstrap steps failed.")
552
674
  key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
@@ -692,49 +814,166 @@ class CreateProxmoxContainerHandler(SyncHandler):
692
814
 
693
815
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
694
816
  logger.info("create_proxmox_container command received")
695
- if os.geteuid() != 0:
696
- logger.error("container creation rejected: not running as root")
697
- raise PermissionError("Container creation requires root privileges.")
698
-
699
- config = _load_config()
700
- if not config or not config.get("token_value"):
701
- logger.error("container creation rejected: infra not configured")
702
- raise ValueError("Proxmox infrastructure is not configured.")
703
- if not config.get("network", {}).get("applied"):
704
- logger.error("container creation rejected: network bridge not applied")
705
- raise RuntimeError("Proxmox bridge setup must be applied before creating containers.")
706
-
707
- proxmox = _connect_proxmox(config)
708
- node = config.get("node") or DEFAULT_NODE_NAME
709
- payload = _build_container_payload(message, config)
710
- payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
711
- payload["memory"] = int(payload["ram_mib"])
712
- payload["node"] = node
713
- logger.debug(
714
- "Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
715
- node,
716
- payload["template"],
717
- payload["ram_mib"],
718
- payload["cpus"],
719
- payload["storage"],
817
+ request_id = message.get("request_id")
818
+ bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
819
+ bootstrap_steps = _build_bootstrap_steps(bootstrap_user, bootstrap_password, bootstrap_ssh_key)
820
+ total_steps = 3 + len(bootstrap_steps) + 2
821
+ current_step_index = 1
822
+
823
+ def _run_lifecycle_step(
824
+ step_name: str,
825
+ step_label: str,
826
+ start_message: str,
827
+ success_message: str,
828
+ action,
829
+ ):
830
+ nonlocal current_step_index
831
+ step_index = current_step_index
832
+ _emit_progress_event(
833
+ step_index=step_index,
834
+ total_steps=total_steps,
835
+ step_name=step_name,
836
+ step_label=step_label,
837
+ status="in_progress",
838
+ message=start_message,
839
+ phase="lifecycle",
840
+ request_id=request_id,
841
+ )
842
+ try:
843
+ result = action()
844
+ except Exception as exc:
845
+ _emit_progress_event(
846
+ step_index=step_index,
847
+ total_steps=total_steps,
848
+ step_name=step_name,
849
+ step_label=step_label,
850
+ status="failed",
851
+ message=f"{step_label} failed: {exc}",
852
+ phase="lifecycle",
853
+ request_id=request_id,
854
+ details={"error": str(exc)},
855
+ )
856
+ raise
857
+ _emit_progress_event(
858
+ step_index=step_index,
859
+ total_steps=total_steps,
860
+ step_name=step_name,
861
+ step_label=step_label,
862
+ status="completed",
863
+ message=success_message,
864
+ phase="lifecycle",
865
+ request_id=request_id,
866
+ )
867
+ current_step_index += 1
868
+ return result
869
+
870
+ def _validate_environment():
871
+ if os.geteuid() != 0:
872
+ raise PermissionError("Container creation requires root privileges.")
873
+ config = _load_config()
874
+ if not config or not config.get("token_value"):
875
+ raise ValueError("Proxmox infrastructure is not configured.")
876
+ if not config.get("network", {}).get("applied"):
877
+ raise RuntimeError("Proxmox bridge setup must be applied before creating containers.")
878
+ return config
879
+
880
+ config = _run_lifecycle_step(
881
+ "validate_environment",
882
+ "Validating infrastructure",
883
+ "Checking token, permissions, and bridge setup…",
884
+ "Infrastructure validated.",
885
+ _validate_environment,
720
886
  )
721
887
 
722
- try:
888
+ def _create_container():
889
+ proxmox = _connect_proxmox(config)
890
+ node = config.get("node") or DEFAULT_NODE_NAME
891
+ payload = _build_container_payload(message, config)
892
+ payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
893
+ payload["memory"] = int(payload["ram_mib"])
894
+ payload["node"] = node
895
+ logger.debug(
896
+ "Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
897
+ node,
898
+ payload["template"],
899
+ payload["ram_mib"],
900
+ payload["cpus"],
901
+ payload["storage"],
902
+ )
723
903
  vmid, _ = _instantiate_container(proxmox, node, payload)
724
- except Exception as exc:
725
- logger.exception("container instantiation failed")
726
- raise
727
-
728
- payload["vmid"] = vmid
729
- payload["created_at"] = datetime.utcnow().isoformat() + "Z"
730
- _write_container_record(vmid, payload)
904
+ payload["vmid"] = vmid
905
+ payload["created_at"] = datetime.utcnow().isoformat() + "Z"
906
+ _write_container_record(vmid, payload)
907
+ return proxmox, node, vmid, payload
908
+
909
+ proxmox, node, vmid, payload = _run_lifecycle_step(
910
+ "create_container",
911
+ "Creating container",
912
+ "Provisioning the LXC container…",
913
+ "Container created.",
914
+ _create_container,
915
+ )
731
916
 
732
- try:
917
+ def _start_container_step():
733
918
  _start_container(proxmox, node, vmid)
734
- public_key, steps = _bootstrap_portacode(vmid, payload["username"], payload["password"], payload["ssh_public_key"])
735
- except Exception:
736
- logger.exception("failed to start/setup container %s", vmid)
737
- raise
919
+
920
+ _run_lifecycle_step(
921
+ "start_container",
922
+ "Starting container",
923
+ "Booting the container…",
924
+ "Container startup completed.",
925
+ _start_container_step,
926
+ )
927
+
928
+ def _bootstrap_progress_callback(
929
+ step_index: int,
930
+ total: int,
931
+ step: Dict[str, Any],
932
+ status: str,
933
+ result: Optional[Dict[str, Any]],
934
+ ):
935
+ label = step.get("display_name") or _friendly_step_label(step.get("name", "bootstrap"))
936
+ error_summary = (result or {}).get("error_summary") or (result or {}).get("error")
937
+ attempt = (result or {}).get("attempt")
938
+ if status == "in_progress":
939
+ message_text = f"{label} is running…"
940
+ elif status == "completed":
941
+ message_text = f"{label} completed."
942
+ elif status == "retrying":
943
+ attempt_desc = f" (attempt {attempt})" if attempt else ""
944
+ message_text = f"{label} failed{attempt_desc}; retrying…"
945
+ else:
946
+ message_text = f"{label} failed"
947
+ if error_summary:
948
+ message_text += f": {error_summary}"
949
+ details: Dict[str, Any] = {}
950
+ if attempt:
951
+ details["attempt"] = attempt
952
+ if error_summary:
953
+ details["error_summary"] = error_summary
954
+ _emit_progress_event(
955
+ step_index=step_index,
956
+ total_steps=total,
957
+ step_name=step.get("name", "bootstrap"),
958
+ step_label=label,
959
+ status=status,
960
+ message=message_text,
961
+ phase="bootstrap",
962
+ request_id=request_id,
963
+ details=details or None,
964
+ )
965
+
966
+ public_key, steps = _bootstrap_portacode(
967
+ vmid,
968
+ payload["username"],
969
+ payload["password"],
970
+ payload["ssh_public_key"],
971
+ steps=bootstrap_steps,
972
+ progress_callback=_bootstrap_progress_callback,
973
+ start_index=current_step_index,
974
+ total_steps=total_steps,
975
+ )
976
+ current_step_index += len(bootstrap_steps)
738
977
 
739
978
  return {
740
979
  "event": "proxmox_container_created",
@@ -755,6 +994,112 @@ class CreateProxmoxContainerHandler(SyncHandler):
755
994
  }
756
995
 
757
996
 
997
+ class StartPortacodeServiceHandler(SyncHandler):
998
+ """Start the Portacode service inside a newly created container."""
999
+
1000
+ @property
1001
+ def command_name(self) -> str:
1002
+ return "start_portacode_service"
1003
+
1004
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1005
+ ctid = message.get("ctid")
1006
+ if not ctid:
1007
+ raise ValueError("ctid is required")
1008
+ try:
1009
+ vmid = int(ctid)
1010
+ except ValueError:
1011
+ raise ValueError("ctid must be an integer")
1012
+
1013
+ record = _read_container_record(vmid)
1014
+ user = record.get("username")
1015
+ password = record.get("password")
1016
+ if not user or not password:
1017
+ raise RuntimeError("Container credentials unavailable")
1018
+
1019
+ start_index = int(message.get("step_index", 1))
1020
+ total_steps = int(message.get("total_steps", start_index + 2))
1021
+ request_id = message.get("request_id")
1022
+
1023
+ auth_step_name = "setup_device_authentication"
1024
+ auth_label = "Setting up device authentication"
1025
+ _emit_progress_event(
1026
+ self,
1027
+ step_index=start_index,
1028
+ total_steps=total_steps,
1029
+ step_name=auth_step_name,
1030
+ step_label=auth_label,
1031
+ status="in_progress",
1032
+ message="Notifying the server of the new device…",
1033
+ phase="service",
1034
+ request_id=request_id,
1035
+ )
1036
+ _emit_progress_event(
1037
+ self,
1038
+ step_index=start_index,
1039
+ total_steps=total_steps,
1040
+ step_name=auth_step_name,
1041
+ step_label=auth_label,
1042
+ status="completed",
1043
+ message="Authentication metadata recorded.",
1044
+ phase="service",
1045
+ request_id=request_id,
1046
+ )
1047
+
1048
+ install_step = start_index + 1
1049
+ install_label = "Launching Portacode service"
1050
+ _emit_progress_event(
1051
+ self,
1052
+ step_index=install_step,
1053
+ total_steps=total_steps,
1054
+ step_name="launch_portacode_service",
1055
+ step_label=install_label,
1056
+ status="in_progress",
1057
+ message="Running sudo portacode service install…",
1058
+ phase="service",
1059
+ request_id=request_id,
1060
+ )
1061
+
1062
+ cmd = f"su - {user} -c 'sudo -S portacode service install'"
1063
+ res = _run_pct(vmid, cmd, input_text=password + "\n")
1064
+
1065
+ if res["returncode"] != 0:
1066
+ _emit_progress_event(
1067
+ self,
1068
+ step_index=install_step,
1069
+ total_steps=total_steps,
1070
+ step_name="launch_portacode_service",
1071
+ step_label=install_label,
1072
+ status="failed",
1073
+ message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
1074
+ phase="service",
1075
+ request_id=request_id,
1076
+ details={
1077
+ "stderr": res.get("stderr"),
1078
+ "stdout": res.get("stdout"),
1079
+ },
1080
+ )
1081
+ raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1082
+
1083
+ _emit_progress_event(
1084
+ self,
1085
+ step_index=install_step,
1086
+ total_steps=total_steps,
1087
+ step_name="launch_portacode_service",
1088
+ step_label=install_label,
1089
+ status="completed",
1090
+ message="Portacode service install finished.",
1091
+ phase="service",
1092
+ request_id=request_id,
1093
+ )
1094
+
1095
+ return {
1096
+ "event": "proxmox_service_started",
1097
+ "success": True,
1098
+ "message": "Portacode service install completed",
1099
+ "ctid": str(vmid),
1100
+ }
1101
+
1102
+
758
1103
  class ConfigureProxmoxInfraHandler(SyncHandler):
759
1104
  @property
760
1105
  def command_name(self) -> str:
@@ -55,6 +55,7 @@ from .handlers import (
55
55
  ConfigureProxmoxInfraHandler,
56
56
  CreateProxmoxContainerHandler,
57
57
  RevertProxmoxInfraHandler,
58
+ StartPortacodeServiceHandler,
58
59
  )
59
60
  from .handlers.project_aware_file_handlers import (
60
61
  ProjectAwareFileWriteHandler,
@@ -414,6 +415,7 @@ class TerminalManager:
414
415
  "mux": mux,
415
416
  "use_content_caching": True, # Enable content caching optimization
416
417
  "debug": self.debug,
418
+ "event_loop": asyncio.get_running_loop(),
417
419
  }
418
420
 
419
421
  # Initialize command registry
@@ -477,6 +479,7 @@ class TerminalManager:
477
479
  # System management handlers
478
480
  self._command_registry.register(ConfigureProxmoxInfraHandler)
479
481
  self._command_registry.register(CreateProxmoxContainerHandler)
482
+ self._command_registry.register(StartPortacodeServiceHandler)
480
483
  self._command_registry.register(RevertProxmoxInfraHandler)
481
484
  self._command_registry.register(UpdatePortacodeHandler)
482
485
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.11.dev5
3
+ Version: 1.4.11.dev7
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,7 +1,7 @@
1
1
  portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
2
2
  portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
3
3
  portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
4
- portacode/_version.py,sha256=etzGixK9D-N8iE-N-EQixfapn6X63fGLccK__6i42Js,719
4
+ portacode/_version.py,sha256=rMZzV0VJav1wTlnbu-swqozHj1iDFuOjk8XIEpAzY8o,719
5
5
  portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
6
6
  portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
7
7
  portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
@@ -12,17 +12,17 @@ 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=NFkbbJe4Rz5XUi1WYEf9kXJqH8ETqqC9tcQhVORG3Y8,44599
15
+ portacode/connection/terminal.py,sha256=07wxG_55JMy3yQ9TXCBldW9h43qCW3U8rv2yzGMx4FM,44757
16
16
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
17
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=1cYtf0iJBtY_Q4dadmKSdcHd67zg1f2mf-Gnm4-oI08,90511
18
- portacode/connection/handlers/__init__.py,sha256=iiyF3smwiI0IeDYzWQTl2PPVfW6aSp-g2CSO1ZTo9Ho,2641
17
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=Z5L0gJWoHxV7UonHqxHko_PXZd7Z1mbI6yWtmjxna-s,93951
18
+ portacode/connection/handlers/__init__.py,sha256=y-Aj5SXqc_QJt7i1xkl7kv381Fd2CIcUiG5gR1F8qkI,2711
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=P9XjBuHkqRsJYK9Y_Ei7kTyK__cwkf2u-W5-3uVB_cw,29286
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=KeYgb7IVdK_nSPJmrPp2W0HR0rlNCQ46DTLRQ63OF9Y,41117
26
26
  portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
27
27
  portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
28
28
  portacode/connection/handlers/system_handlers.py,sha256=AKh7IbwptlLYrbSw5f-DHigvlaKHsg9lDP-lkAUm8cE,10755
@@ -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.dev5.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
67
+ portacode-1.4.11.dev7.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.dev5.dist-info/METADATA,sha256=0TExN_oiooXn-VrOIp0aLXmIlzHNkZowv3z-aqy7rA4,13051
94
- portacode-1.4.11.dev5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
95
- portacode-1.4.11.dev5.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
96
- portacode-1.4.11.dev5.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
97
- portacode-1.4.11.dev5.dist-info/RECORD,,
93
+ portacode-1.4.11.dev7.dist-info/METADATA,sha256=6A_o1zGxAkhlk0n-T3_tozO_7X-fdVO3n6RXjOCCBvs,13051
94
+ portacode-1.4.11.dev7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
95
+ portacode-1.4.11.dev7.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
96
+ portacode-1.4.11.dev7.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
97
+ portacode-1.4.11.dev7.dist-info/RECORD,,