portacode 1.4.11.dev5__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 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.12.dev1'
32
+ __version_tuple__ = version_tuple = (1, 4, 12, 'dev1')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -367,6 +367,45 @@ Creates a Portacode-managed LXC container, starts it, and bootstraps the Portaco
367
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
368
  * On failure, the device will emit an [`error`](#error) event.
369
369
 
370
+ ### `start_proxmox_container`
371
+
372
+ Starts a previously provisioned, Portacode-managed LXC container. Handled by [`StartProxmoxContainerHandler`](./proxmox_infra.py).
373
+
374
+ **Payload Fields:**
375
+
376
+ * `ctid` (string, required): Identifier of the container to start.
377
+
378
+ **Responses:**
379
+
380
+ * Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="start"` and the refreshed infra snapshot.
381
+ * Emits an [`error`](#error) event when the request cannot be fulfilled (e.g., missing infra config, CT not tagged as managed, or API failure).
382
+
383
+ ### `stop_proxmox_container`
384
+
385
+ Stops a running Portacode-managed container. Handled by [`StopProxmoxContainerHandler`](./proxmox_infra.py).
386
+
387
+ **Payload Fields:**
388
+
389
+ * `ctid` (string, required): Identifier of the container to stop.
390
+
391
+ **Responses:**
392
+
393
+ * Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="stop"` and the refreshed infra snapshot.
394
+ * Emits an [`error`](#error) event on failure.
395
+
396
+ ### `remove_proxmox_container`
397
+
398
+ Deletes a managed container from Proxmox (stopping it first if necessary) and removes the stored metadata file. Handled by [`RemoveProxmoxContainerHandler`](./proxmox_infra.py).
399
+
400
+ **Payload Fields:**
401
+
402
+ * `ctid` (string, required): Identifier of the container to delete.
403
+
404
+ **Responses:**
405
+
406
+ * Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="remove"` and the refreshed infra snapshot after deletion.
407
+ * Emits an [`error`](#error) event on failure.
408
+
370
409
  ### `proxmox_container_created`
371
410
 
372
411
  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.
@@ -380,6 +419,48 @@ Emitted after a successful `create_proxmox_container` action. Contains the new c
380
419
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
381
420
  * `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
382
421
 
422
+ ### `proxmox_container_progress`
423
+
424
+ 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.
425
+
426
+ **Event Fields:**
427
+
428
+ * `step_index` (integer): 1-based index of the current step inside the entire provisioning sequence.
429
+ * `total_steps` (integer): Total number of steps that must run before provisioning completes.
430
+ * `step_name` (string): Internal step identifier (e.g., `create_container`, `apt_update`, `portacode_connect`).
431
+ * `step_label` (string): Human-friendly label suitable for UI (e.g., `Create container`, `Apt update`).
432
+ * `status` (string): One of `in_progress`, `completed`, or `failed`.
433
+ * `phase` (string): Either `lifecycle` (environment/container lifecycle) or `bootstrap` (per-command bootstrap work).
434
+ * `message` (string): Short description of what is happening or why a failure occurred.
435
+ * `details` (object, optional): Contains `attempt` (if retries were needed) and `error_summary` when a step fails.
436
+ * `request_id` (string, optional): Mirrors the request ID from the incoming `create_proxmox_container` payload when available.
437
+
438
+ ### `start_portacode_service`
439
+
440
+ Runs `sudo portacode service install` inside the container after the dashboard has created the corresponding Device record with the supplied public key.
441
+
442
+ **Payload Fields:**
443
+
444
+ * `ctid` (string, required): Container ID target.
445
+ * `step_index` (integer, required): Next step index to render inside `proxmox_container_progress`.
446
+ * `total_steps` (integer, required): The overall total number of steps (including lifecycle, bootstrap, and service installation).
447
+
448
+ **Responses:**
449
+
450
+ * Emits additional [`proxmox_container_progress`](#proxmox_container_progress-event) events to report the authentication and service-install steps.
451
+ * On success, emits a [`proxmox_service_started`](#proxmox_service_started-event).
452
+ * On failure, emits a generic [`error`](#error) event.
453
+
454
+ ### `proxmox_service_started`
455
+
456
+ Indicates that `portacode service install` finished successfully inside a managed container.
457
+
458
+ **Event Fields:**
459
+
460
+ * `success` (boolean): True when the install succeeded.
461
+ * `message` (string): Success summary (e.g., `Portacode service install completed`).
462
+ * `ctid` (string): Container ID.
463
+
383
464
  ### `clock_sync_request`
384
465
 
385
466
  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).
@@ -1025,21 +1106,37 @@ Provides system information in response to a `system_info` action. Handled by [`
1025
1106
  * `proxmox` (object): Detection hints for Proxmox VE nodes:
1026
1107
  * `is_proxmox_node` (boolean): True when Proxmox artifacts (e.g., `/etc/proxmox-release`) exist.
1027
1108
  * `version` (string|null): Raw contents of `/etc/proxmox-release` when readable.
1028
- * `infra` (object): Portacode infrastructure configuration snapshot:
1029
- * `configured` (boolean): True when `setup_proxmox_infra` stored an API token.
1030
- * `host` (string|null): Hostname used for the API client (usually `localhost`).
1031
- * `node` (string|null): Proxmox node name that was targeted.
1032
- * `user` (string|null): API token owner (e.g., `root@pam`).
1033
- * `token_name` (string|null): API token identifier.
1034
- * `default_storage` (string|null): Storage pool chosen for future containers.
1035
- * `templates` (array[string]): Cached list of available LXC templates.
1036
- * `last_verified` (string|null): ISO timestamp when the token was last validated.
1037
- * `network` (object):
1038
- * `applied` (boolean): True when the bridge/NAT services were successfully configured.
1039
- * `message` (string|null): Informational text about the network setup attempt.
1040
- * `bridge` (string): The bridge interface configured (typically `vmbr1`).
1041
- * `health` (string|null): `"healthy"` when the connectivity verification succeeded.
1042
- * `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
1109
+ * `infra` (object): Portacode infrastructure configuration snapshot:
1110
+ * `configured` (boolean): True when `setup_proxmox_infra` stored an API token.
1111
+ * `host` (string|null): Hostname used for the API client (usually `localhost`).
1112
+ * `node` (string|null): Proxmox node name that was targeted.
1113
+ * `user` (string|null): API token owner (e.g., `root@pam`).
1114
+ * `token_name` (string|null): API token identifier.
1115
+ * `default_storage` (string|null): Storage pool chosen for future containers.
1116
+ * `templates` (array[string]): Cached list of available LXC templates.
1117
+ * `last_verified` (string|null): ISO timestamp when the token was last validated.
1118
+ * `network` (object):
1119
+ * `applied` (boolean): True when the bridge/NAT services were successfully configured.
1120
+ * `message` (string|null): Informational text about the network setup attempt.
1121
+ * `bridge` (string): The bridge interface configured (typically `vmbr1`).
1122
+ * `health` (string|null): `"healthy"` when the connectivity verification succeeded.
1123
+ * `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
1124
+ * `managed_containers` (object): Cached summary of the Portacode-managed containers:
1125
+ * `updated_at` (string): ISO timestamp when this snapshot was last refreshed.
1126
+ * `count` (integer): Number of managed containers.
1127
+ * `total_ram_mib` (integer): RAM footprint summed across all containers.
1128
+ * `total_disk_gib` (integer): Disk footprint summed across all containers.
1129
+ * `total_cpu_share` (number): CPU shares requested across all containers.
1130
+ * `containers` (array[object]): Container summaries with the following fields:
1131
+ * `vmid` (string|null): Numeric CT ID.
1132
+ * `hostname` (string|null): Hostname configured in the CT.
1133
+ * `template` (string|null): Template identifier used.
1134
+ * `storage` (string|null): Storage pool backing the rootfs.
1135
+ * `disk_gib` (integer): Rootfs size in GiB.
1136
+ * `ram_mib` (integer): Memory size in MiB.
1137
+ * `cpu_share` (number): vCPU-equivalent share requested at creation.
1138
+ * `status` (string): Lowercase lifecycle status (e.g., `running`, `stopped`, `deleted`).
1139
+ * `created_at` (string|null): ISO timestamp recorded when the CT was provisioned.
1043
1140
  * `portacode_version` (string): Installed CLI version returned by `portacode.__version__`.
1044
1141
 
1045
1142
  ### `proxmox_infra_configured`
@@ -1075,6 +1172,36 @@ Emitted after a successful `create_proxmox_container` action to report the newly
1075
1172
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
1076
1173
  * `setup_steps` (array[object]): Detailed bootstrap step reports including stdout/stderr, elapsed time, and pass/fail status.
1077
1174
 
1175
+ ### `proxmox_container_progress`
1176
+
1177
+ Sent continuously while `create_proxmox_container` runs so dashboards can show a progress bar tied to each lifecycle and bootstrap step.
1178
+
1179
+ **Event Fields:**
1180
+
1181
+ * `step_index` (integer): 1-based position of the step inside the entire provisioning workflow.
1182
+ * `total_steps` (integer): Total number of lifecycle and bootstrap steps for the current operation.
1183
+ * `step_name` (string): Internal identifier (e.g., `validate_environment`, `install_deps`, `portacode_connect`).
1184
+ * `step_label` (string): Friendly label suitable for the UI.
1185
+ * `status` (string): One of `in_progress`, `completed`, or `failed`.
1186
+ * `phase` (string): Either `lifecycle` (node validation/container lifecycle) or `bootstrap` (commands run inside the CT).
1187
+ * `message` (string): Short human-readable description of the action or failure.
1188
+ * `details` (object, optional): Contains `attempt` (when retries are used) and `error_summary` on failure.
1189
+ * `request_id` (string, optional): Mirrors the `create_proxmox_container` request when provided.
1190
+
1191
+ ### `proxmox_container_action`
1192
+
1193
+ Emitted after `start_proxmox_container`, `stop_proxmox_container`, or `remove_proxmox_container` commands complete. Each event includes the refreshed infra snapshot so dashboards can immediately display the latest managed container totals even though the `proxmox.infra.managed_containers` cache updates only every ~30 seconds.
1194
+
1195
+ **Event Fields:**
1196
+
1197
+ * `action` (string): The action that ran (`start`, `stop`, or `remove`).
1198
+ * `success` (boolean): True when the requested action succeeded.
1199
+ * `ctid` (string): Target CT ID.
1200
+ * `message` (string): Human-friendly summary (e.g., `Stopped container 103`).
1201
+ * `status` (string): The container’s new status (e.g., `running`, `stopped`, `deleted`).
1202
+ * `details` (object, optional): Exit status information (e.g., `exitstatus`, `stop_exitstatus`, `delete_exitstatus`).
1203
+ * `infra` (object): Same snapshot described under [`system_info`](#system_info-event) `proxmox.infra`, including the updated `managed_containers` summary.
1204
+
1078
1205
  ### <a name="clock_sync_response"></a>`clock_sync_response`
1079
1206
 
1080
1207
  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,10 @@ from .proxmox_infra import (
45
45
  ConfigureProxmoxInfraHandler,
46
46
  CreateProxmoxContainerHandler,
47
47
  RevertProxmoxInfraHandler,
48
+ StartPortacodeServiceHandler,
49
+ StartProxmoxContainerHandler,
50
+ StopProxmoxContainerHandler,
51
+ RemoveProxmoxContainerHandler,
48
52
  )
49
53
 
50
54
  __all__ = [
@@ -84,6 +88,10 @@ __all__ = [
84
88
  "ProjectStateGitUnstageHandler",
85
89
  "ProjectStateGitRevertHandler",
86
90
  "ProjectStateGitCommitHandler",
91
+ "StartPortacodeServiceHandler",
92
+ "StartProxmoxContainerHandler",
93
+ "StopProxmoxContainerHandler",
94
+ "RemoveProxmoxContainerHandler",
87
95
  "UpdatePortacodeHandler",
88
96
  "RevertProxmoxInfraHandler",
89
97
  ]
@@ -2,17 +2,20 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
6
7
  import logging
7
8
  import os
9
+ import secrets
8
10
  import shutil
9
11
  import stat
10
12
  import subprocess
11
13
  import sys
12
14
  import time
15
+ import threading
13
16
  from datetime import datetime
14
17
  from pathlib import Path
15
- from typing import Any, Dict, Iterable, List, Optional, Tuple
18
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
16
19
 
17
20
  import platformdirs
18
21
 
@@ -38,6 +41,58 @@ DNS_SERVER = "1.1.1.1"
38
41
  IFACES_PATH = Path("/etc/network/interfaces")
39
42
  SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
40
43
  UNIT_DIR = Path("/etc/systemd/system")
44
+ _MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
45
+ _MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
46
+ _MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
47
+
48
+ ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
49
+
50
+
51
+ def _emit_progress_event(
52
+ handler: SyncHandler,
53
+ *,
54
+ step_index: int,
55
+ total_steps: int,
56
+ step_name: str,
57
+ step_label: str,
58
+ status: str,
59
+ message: str,
60
+ phase: str,
61
+ request_id: Optional[str],
62
+ details: Optional[Dict[str, Any]] = None,
63
+ ) -> None:
64
+ loop = handler.context.get("event_loop")
65
+ if not loop or loop.is_closed():
66
+ logger.debug(
67
+ "progress event skipped (no event loop) step=%s status=%s",
68
+ step_name,
69
+ status,
70
+ )
71
+ return
72
+
73
+ payload: Dict[str, Any] = {
74
+ "event": "proxmox_container_progress",
75
+ "step_name": step_name,
76
+ "step_label": step_label,
77
+ "status": status,
78
+ "phase": phase,
79
+ "step_index": step_index,
80
+ "total_steps": total_steps,
81
+ "message": message,
82
+ }
83
+ if request_id:
84
+ payload["request_id"] = request_id
85
+ if details:
86
+ payload["details"] = details
87
+
88
+ future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
89
+ future.add_done_callback(
90
+ lambda fut: logger.warning(
91
+ "Failed to emit progress event for %s: %s", step_name, fut.exception()
92
+ )
93
+ if fut.exception()
94
+ else None
95
+ )
41
96
 
42
97
 
43
98
  def _call_subprocess(cmd: List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
@@ -239,12 +294,185 @@ def _ensure_containers_dir() -> None:
239
294
  CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
240
295
 
241
296
 
297
+ def _invalidate_managed_containers_cache() -> None:
298
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
299
+ _MANAGED_CONTAINERS_CACHE["timestamp"] = 0.0
300
+ _MANAGED_CONTAINERS_CACHE["summary"] = None
301
+
302
+
303
+ def _load_managed_container_records() -> List[Dict[str, Any]]:
304
+ _ensure_containers_dir()
305
+ records: List[Dict[str, Any]] = []
306
+ for path in sorted(CONTAINERS_DIR.glob("ct-*.json")):
307
+ try:
308
+ payload = json.loads(path.read_text(encoding="utf-8"))
309
+ except Exception as exc: # pragma: no cover - best effort logging
310
+ logger.debug("Unable to read container record %s: %s", path, exc)
311
+ continue
312
+ records.append(payload)
313
+ return records
314
+
315
+
316
+ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str, Any]:
317
+ total_ram = 0
318
+ total_disk = 0
319
+ total_cpu_share = 0.0
320
+ containers: List[Dict[str, Any]] = []
321
+
322
+ def _as_int(value: Any) -> int:
323
+ try:
324
+ return int(value)
325
+ except (TypeError, ValueError):
326
+ return 0
327
+
328
+ def _as_float(value: Any) -> float:
329
+ try:
330
+ return float(value)
331
+ except (TypeError, ValueError):
332
+ return 0.0
333
+
334
+ for record in sorted(records, key=lambda entry: _as_int(entry.get("vmid"))):
335
+ ram_mib = _as_int(record.get("ram_mib"))
336
+ disk_gib = _as_int(record.get("disk_gib"))
337
+ cpu_share = _as_float(record.get("cpus"))
338
+ total_ram += ram_mib
339
+ total_disk += disk_gib
340
+ total_cpu_share += cpu_share
341
+ status = (record.get("status") or "unknown").lower()
342
+ containers.append(
343
+ {
344
+ "vmid": str(_as_int(record.get("vmid"))) if record.get("vmid") is not None else None,
345
+ "hostname": record.get("hostname"),
346
+ "template": record.get("template"),
347
+ "storage": record.get("storage"),
348
+ "disk_gib": disk_gib,
349
+ "ram_mib": ram_mib,
350
+ "cpu_share": cpu_share,
351
+ "created_at": record.get("created_at"),
352
+ "status": status,
353
+ }
354
+ )
355
+
356
+ return {
357
+ "updated_at": datetime.utcnow().isoformat() + "Z",
358
+ "count": len(containers),
359
+ "total_ram_mib": total_ram,
360
+ "total_disk_gib": total_disk,
361
+ "total_cpu_share": round(total_cpu_share, 2),
362
+ "containers": containers,
363
+ }
364
+
365
+
366
+ def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
367
+ def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
368
+ if not records or not config:
369
+ return
370
+ try:
371
+ proxmox = _connect_proxmox(config)
372
+ node = _get_node_from_config(config)
373
+ statuses = {
374
+ str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
375
+ for ct in proxmox.nodes(node).lxc.get()
376
+ }
377
+ except Exception as exc: # pragma: no cover - best effort
378
+ logger.debug("Failed to refresh container statuses: %s", exc)
379
+ return
380
+ for record in records:
381
+ vmid = record.get("vmid")
382
+ if vmid is None:
383
+ continue
384
+ try:
385
+ vmid_key = str(int(vmid))
386
+ except (ValueError, TypeError):
387
+ continue
388
+ status = statuses.get(vmid_key)
389
+ if status:
390
+ record["status"] = status
391
+
392
+ now = time.monotonic()
393
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
394
+ cache_ts = _MANAGED_CONTAINERS_CACHE["timestamp"]
395
+ cached = _MANAGED_CONTAINERS_CACHE["summary"]
396
+ if not force and cached and now - cache_ts < _MANAGED_CONTAINERS_CACHE_TTL_S:
397
+ return cached
398
+ config = _load_config()
399
+ records = _load_managed_container_records()
400
+ _refresh_container_statuses(records, config)
401
+ summary = _build_managed_containers_summary(records)
402
+ with _MANAGED_CONTAINERS_CACHE_LOCK:
403
+ _MANAGED_CONTAINERS_CACHE["timestamp"] = now
404
+ _MANAGED_CONTAINERS_CACHE["summary"] = summary
405
+ return summary
406
+
407
+
242
408
  def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
243
409
  if storage_type in ("lvm", "lvmthin"):
244
410
  return f"{storage}:{disk_gib}"
245
411
  return f"{storage}:{disk_gib}G"
246
412
 
247
413
 
414
+ def _get_provisioning_user_info(message: Dict[str, Any]) -> Tuple[str, str, str]:
415
+ user = (message.get("username") or "svcuser").strip() if message else "svcuser"
416
+ user = user or "svcuser"
417
+ password = message.get("password")
418
+ if not password:
419
+ password = secrets.token_urlsafe(10)
420
+ ssh_key = (message.get("ssh_key") or "").strip() if message else ""
421
+ return user, password, ssh_key
422
+
423
+
424
+ def _friendly_step_label(step_name: str) -> str:
425
+ if not step_name:
426
+ return "Step"
427
+ normalized = step_name.replace("_", " ").strip()
428
+ return normalized.capitalize()
429
+
430
+
431
+ def _build_bootstrap_steps(user: str, password: str, ssh_key: str) -> List[Dict[str, Any]]:
432
+ steps = [
433
+ {
434
+ "name": "apt_update",
435
+ "cmd": "apt-get update -y",
436
+ "retries": 4,
437
+ "retry_delay_s": 5,
438
+ "retry_on": [
439
+ "Temporary failure resolving",
440
+ "Could not resolve",
441
+ "Failed to fetch",
442
+ ],
443
+ },
444
+ {
445
+ "name": "install_deps",
446
+ "cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
447
+ "retries": 5,
448
+ "retry_delay_s": 5,
449
+ "retry_on": [
450
+ "lock-frontend",
451
+ "Unable to acquire the dpkg frontend lock",
452
+ "Temporary failure resolving",
453
+ "Could not resolve",
454
+ "Failed to fetch",
455
+ ],
456
+ },
457
+ {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
458
+ {"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
459
+ ]
460
+ if password:
461
+ steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
462
+ if ssh_key:
463
+ steps.append({
464
+ "name": "add_ssh_key",
465
+ "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
466
+ "retries": 0,
467
+ })
468
+ steps.extend([
469
+ {"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
470
+ {"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
471
+ {"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30},
472
+ ])
473
+ return steps
474
+
475
+
248
476
  def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
249
477
  for entry in storages:
250
478
  if entry.get("storage") == storage_name:
@@ -252,14 +480,14 @@ def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) ->
252
480
  return ""
253
481
 
254
482
 
255
- def _validate_positive_int(value: Any, default: int) -> int:
483
+ def _validate_positive_number(value: Any, default: float) -> float:
256
484
  try:
257
- candidate = int(value)
485
+ candidate = float(value)
258
486
  if candidate > 0:
259
487
  return candidate
260
488
  except Exception:
261
489
  pass
262
- return default
490
+ return float(default)
263
491
 
264
492
 
265
493
  def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
@@ -311,10 +539,44 @@ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any]
311
539
  return _wait_for_task(proxmox, node, upid)
312
540
 
313
541
 
542
+ def _stop_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
543
+ status = proxmox.nodes(node).lxc(vmid).status.current.get()
544
+ if status.get("status") != "running":
545
+ return status, 0.0
546
+ upid = proxmox.nodes(node).lxc(vmid).status.stop.post()
547
+ return _wait_for_task(proxmox, node, upid)
548
+
549
+
550
+ def _delete_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
551
+ upid = proxmox.nodes(node).lxc(vmid).delete()
552
+ return _wait_for_task(proxmox, node, upid)
553
+
554
+
314
555
  def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
315
556
  _ensure_containers_dir()
316
557
  path = CONTAINERS_DIR / f"ct-{vmid}.json"
317
558
  path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
559
+ _invalidate_managed_containers_cache()
560
+
561
+
562
+ def _read_container_record(vmid: int) -> Dict[str, Any]:
563
+ path = CONTAINERS_DIR / f"ct-{vmid}.json"
564
+ if not path.exists():
565
+ raise FileNotFoundError(f"Container record {path} missing")
566
+ return json.loads(path.read_text(encoding="utf-8"))
567
+
568
+
569
+ def _update_container_record(vmid: int, updates: Dict[str, Any]) -> None:
570
+ record = _read_container_record(vmid)
571
+ record.update(updates)
572
+ _write_container_record(vmid, record)
573
+
574
+
575
+ def _remove_container_record(vmid: int) -> None:
576
+ path = CONTAINERS_DIR / f"ct-{vmid}.json"
577
+ if path.exists():
578
+ path.unlink()
579
+ _invalidate_managed_containers_cache()
318
580
 
319
581
 
320
582
  def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
@@ -326,16 +588,14 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
326
588
 
327
589
  bridge = config.get("network", {}).get("bridge", DEFAULT_BRIDGE)
328
590
  hostname = (message.get("hostname") or "").strip()
329
- disk_gib = _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)
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)
332
594
  storage = message.get("storage") or config.get("default_storage") or ""
333
595
  if not storage:
334
596
  raise ValueError("Storage pool could not be determined.")
335
597
 
336
- user = (message.get("username") or "svcuser").strip() or "svcuser"
337
- password = message.get("password") or ""
338
- ssh_key = (message.get("ssh_key") or "").strip()
598
+ user, password, ssh_key = _get_provisioning_user_info(message)
339
599
 
340
600
  payload = {
341
601
  "template": template,
@@ -355,6 +615,38 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
355
615
  return payload
356
616
 
357
617
 
618
+ def _ensure_infra_configured() -> Dict[str, Any]:
619
+ config = _load_config()
620
+ if not config or not config.get("token_value"):
621
+ raise RuntimeError("Proxmox infrastructure is not configured.")
622
+ return config
623
+
624
+
625
+ def _get_node_from_config(config: Dict[str, Any]) -> str:
626
+ return config.get("node") or DEFAULT_NODE_NAME
627
+
628
+
629
+ def _parse_ctid(message: Dict[str, Any]) -> int:
630
+ for key in ("ctid", "vmid"):
631
+ value = message.get(key)
632
+ if value is not None:
633
+ try:
634
+ return int(str(value).strip())
635
+ except ValueError:
636
+ raise ValueError(f"{key} must be an integer") from None
637
+ raise ValueError("ctid is required")
638
+
639
+
640
+ def _ensure_container_managed(
641
+ proxmox: Any, node: str, vmid: int
642
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
643
+ record = _read_container_record(vmid)
644
+ ct_cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
645
+ if not ct_cfg or MANAGED_MARKER not in (ct_cfg.get("description") or ""):
646
+ raise RuntimeError(f"Container {vmid} is not managed by Portacode.")
647
+ return record, ct_cfg
648
+
649
+
358
650
  def _connect_proxmox(config: Dict[str, Any]) -> Any:
359
651
  ProxmoxAPI = _ensure_proxmoxer()
360
652
  return ProxmoxAPI(
@@ -367,10 +659,10 @@ def _connect_proxmox(config: Dict[str, Any]) -> Any:
367
659
  )
368
660
 
369
661
 
370
- def _run_pct(vmid: int, cmd: str) -> Dict[str, Any]:
662
+ def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str, Any]:
371
663
  full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
372
664
  start = time.time()
373
- proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
665
+ proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, input=input_text)
374
666
  return {
375
667
  "cmd": cmd,
376
668
  "returncode": proc.returncode,
@@ -471,18 +763,36 @@ def _summarize_error(res: Dict[str, Any]) -> str:
471
763
  return "Command failed; see stdout/stderr for details."
472
764
 
473
765
 
474
- def _run_setup_steps(vmid: int, steps: List[Dict[str, Any]], user: str) -> Tuple[List[Dict[str, Any]], bool]:
766
+ def _run_setup_steps(
767
+ vmid: int,
768
+ steps: List[Dict[str, Any]],
769
+ user: str,
770
+ progress_callback: Optional[ProgressCallback] = None,
771
+ start_index: int = 1,
772
+ total_steps: Optional[int] = None,
773
+ ) -> Tuple[List[Dict[str, Any]], bool]:
475
774
  results: List[Dict[str, Any]] = []
476
- for step in steps:
775
+ computed_total = total_steps if total_steps is not None else start_index + len(steps) - 1
776
+ for offset, step in enumerate(steps):
777
+ step_index = start_index + offset
778
+ if progress_callback:
779
+ progress_callback(step_index, computed_total, step, "in_progress", None)
780
+
477
781
  if step.get("type") == "portacode_connect":
478
782
  res = _portacode_connect_and_read_key(vmid, user, timeout_s=step.get("timeout_s", 10))
479
783
  res["name"] = step["name"]
480
784
  results.append(res)
481
785
  if not res.get("ok"):
786
+ if progress_callback:
787
+ progress_callback(step_index, computed_total, step, "failed", res)
482
788
  return results, False
789
+ if progress_callback:
790
+ progress_callback(step_index, computed_total, step, "completed", res)
483
791
  continue
484
792
 
485
793
  attempts = 0
794
+ retry_on = step.get("retry_on", [])
795
+ max_attempts = step.get("retries", 0) + 1
486
796
  while True:
487
797
  attempts += 1
488
798
  res = _run_pct(vmid, step["cmd"])
@@ -492,61 +802,47 @@ def _run_setup_steps(vmid: int, steps: List[Dict[str, Any]], user: str) -> Tuple
492
802
  res["error_summary"] = _summarize_error(res)
493
803
  results.append(res)
494
804
  if res["returncode"] == 0:
805
+ if progress_callback:
806
+ progress_callback(step_index, computed_total, step, "completed", res)
495
807
  break
496
- 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):
808
+
809
+ will_retry = False
810
+ if attempts < max_attempts and retry_on:
811
+ stderr_stdout = (res.get("stderr", "") + res.get("stdout", ""))
812
+ if any(tok in stderr_stdout for tok in retry_on):
813
+ will_retry = True
814
+
815
+ if progress_callback:
816
+ status = "retrying" if will_retry else "failed"
817
+ progress_callback(step_index, computed_total, step, status, res)
818
+
819
+ if will_retry:
500
820
  time.sleep(step.get("retry_delay_s", 3))
501
821
  continue
822
+
502
823
  return results, False
503
824
  return results, True
504
825
 
505
826
 
506
- def _bootstrap_portacode(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)
827
+ def _bootstrap_portacode(
828
+ vmid: int,
829
+ user: str,
830
+ password: str,
831
+ ssh_key: str,
832
+ steps: Optional[List[Dict[str, Any]]] = None,
833
+ progress_callback: Optional[ProgressCallback] = None,
834
+ start_index: int = 1,
835
+ total_steps: Optional[int] = None,
836
+ ) -> Tuple[str, List[Dict[str, Any]]]:
837
+ actual_steps = steps if steps is not None else _build_bootstrap_steps(user, password, ssh_key)
838
+ results, ok = _run_setup_steps(
839
+ vmid,
840
+ actual_steps,
841
+ user,
842
+ progress_callback=progress_callback,
843
+ start_index=start_index,
844
+ total_steps=total_steps,
845
+ )
550
846
  if not ok:
551
847
  raise RuntimeError("Portacode bootstrap steps failed.")
552
848
  key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
@@ -626,6 +922,7 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
626
922
  _save_config(config)
627
923
  snapshot = build_snapshot(config)
628
924
  snapshot["node_status"] = status
925
+ snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
629
926
  return snapshot
630
927
 
631
928
 
@@ -634,6 +931,7 @@ def get_infra_snapshot() -> Dict[str, Any]:
634
931
  snapshot = build_snapshot(config)
635
932
  if config.get("node_status"):
636
933
  snapshot["node_status"] = config["node_status"]
934
+ snapshot["managed_containers"] = _get_managed_containers_summary()
637
935
  return snapshot
638
936
 
639
937
 
@@ -646,6 +944,7 @@ def revert_infrastructure() -> Dict[str, Any]:
646
944
  snapshot["network"]["applied"] = False
647
945
  snapshot["network"]["message"] = "Reverted to previous network state"
648
946
  snapshot["network"]["bridge"] = DEFAULT_BRIDGE
947
+ snapshot["managed_containers"] = _get_managed_containers_summary(force=True)
649
948
  return snapshot
650
949
 
651
950
 
@@ -692,49 +991,171 @@ class CreateProxmoxContainerHandler(SyncHandler):
692
991
 
693
992
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
694
993
  logger.info("create_proxmox_container command received")
695
- 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"],
994
+ request_id = message.get("request_id")
995
+ bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
996
+ bootstrap_steps = _build_bootstrap_steps(bootstrap_user, bootstrap_password, bootstrap_ssh_key)
997
+ total_steps = 3 + len(bootstrap_steps) + 2
998
+ current_step_index = 1
999
+
1000
+ def _run_lifecycle_step(
1001
+ step_name: str,
1002
+ step_label: str,
1003
+ start_message: str,
1004
+ success_message: str,
1005
+ action,
1006
+ ):
1007
+ nonlocal current_step_index
1008
+ step_index = current_step_index
1009
+ _emit_progress_event(self,
1010
+ step_index=step_index,
1011
+ total_steps=total_steps,
1012
+ step_name=step_name,
1013
+ step_label=step_label,
1014
+ status="in_progress",
1015
+ message=start_message,
1016
+ phase="lifecycle",
1017
+ request_id=request_id,
1018
+ )
1019
+ try:
1020
+ result = action()
1021
+ except Exception as exc:
1022
+ _emit_progress_event(
1023
+ self,
1024
+ step_index=step_index,
1025
+ total_steps=total_steps,
1026
+ step_name=step_name,
1027
+ step_label=step_label,
1028
+ status="failed",
1029
+ message=f"{step_label} failed: {exc}",
1030
+ phase="lifecycle",
1031
+ request_id=request_id,
1032
+ details={"error": str(exc)},
1033
+ )
1034
+ raise
1035
+ _emit_progress_event(
1036
+ self,
1037
+ step_index=step_index,
1038
+ total_steps=total_steps,
1039
+ step_name=step_name,
1040
+ step_label=step_label,
1041
+ status="completed",
1042
+ message=success_message,
1043
+ phase="lifecycle",
1044
+ request_id=request_id,
1045
+ )
1046
+ current_step_index += 1
1047
+ return result
1048
+
1049
+ def _validate_environment():
1050
+ if os.geteuid() != 0:
1051
+ raise PermissionError("Container creation requires root privileges.")
1052
+ config = _load_config()
1053
+ if not config or not config.get("token_value"):
1054
+ raise ValueError("Proxmox infrastructure is not configured.")
1055
+ if not config.get("network", {}).get("applied"):
1056
+ raise RuntimeError("Proxmox bridge setup must be applied before creating containers.")
1057
+ return config
1058
+
1059
+ config = _run_lifecycle_step(
1060
+ "validate_environment",
1061
+ "Validating infrastructure",
1062
+ "Checking token, permissions, and bridge setup…",
1063
+ "Infrastructure validated.",
1064
+ _validate_environment,
720
1065
  )
721
1066
 
722
- try:
1067
+ def _create_container():
1068
+ proxmox = _connect_proxmox(config)
1069
+ node = config.get("node") or DEFAULT_NODE_NAME
1070
+ payload = _build_container_payload(message, config)
1071
+ payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
1072
+ payload["memory"] = int(payload["ram_mib"])
1073
+ payload["node"] = node
1074
+ logger.debug(
1075
+ "Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
1076
+ node,
1077
+ payload["template"],
1078
+ payload["ram_mib"],
1079
+ payload["cpus"],
1080
+ payload["storage"],
1081
+ )
723
1082
  vmid, _ = _instantiate_container(proxmox, node, payload)
724
- 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)
1083
+ payload["vmid"] = vmid
1084
+ payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1085
+ payload["status"] = "creating"
1086
+ _write_container_record(vmid, payload)
1087
+ return proxmox, node, vmid, payload
1088
+
1089
+ proxmox, node, vmid, payload = _run_lifecycle_step(
1090
+ "create_container",
1091
+ "Creating container",
1092
+ "Provisioning the LXC container…",
1093
+ "Container created.",
1094
+ _create_container,
1095
+ )
731
1096
 
732
- try:
1097
+ def _start_container_step():
733
1098
  _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
1099
+
1100
+ _run_lifecycle_step(
1101
+ "start_container",
1102
+ "Starting container",
1103
+ "Booting the container…",
1104
+ "Container startup completed.",
1105
+ _start_container_step,
1106
+ )
1107
+ _update_container_record(vmid, {"status": "running"})
1108
+
1109
+ def _bootstrap_progress_callback(
1110
+ step_index: int,
1111
+ total: int,
1112
+ step: Dict[str, Any],
1113
+ status: str,
1114
+ result: Optional[Dict[str, Any]],
1115
+ ):
1116
+ label = step.get("display_name") or _friendly_step_label(step.get("name", "bootstrap"))
1117
+ error_summary = (result or {}).get("error_summary") or (result or {}).get("error")
1118
+ attempt = (result or {}).get("attempt")
1119
+ if status == "in_progress":
1120
+ message_text = f"{label} is running…"
1121
+ elif status == "completed":
1122
+ message_text = f"{label} completed."
1123
+ elif status == "retrying":
1124
+ attempt_desc = f" (attempt {attempt})" if attempt else ""
1125
+ message_text = f"{label} failed{attempt_desc}; retrying…"
1126
+ else:
1127
+ message_text = f"{label} failed"
1128
+ if error_summary:
1129
+ message_text += f": {error_summary}"
1130
+ details: Dict[str, Any] = {}
1131
+ if attempt:
1132
+ details["attempt"] = attempt
1133
+ if error_summary:
1134
+ details["error_summary"] = error_summary
1135
+ _emit_progress_event(
1136
+ self,
1137
+ step_index=step_index,
1138
+ total_steps=total,
1139
+ step_name=step.get("name", "bootstrap"),
1140
+ step_label=label,
1141
+ status=status,
1142
+ message=message_text,
1143
+ phase="bootstrap",
1144
+ request_id=request_id,
1145
+ details=details or None,
1146
+ )
1147
+
1148
+ public_key, steps = _bootstrap_portacode(
1149
+ vmid,
1150
+ payload["username"],
1151
+ payload["password"],
1152
+ payload["ssh_public_key"],
1153
+ steps=bootstrap_steps,
1154
+ progress_callback=_bootstrap_progress_callback,
1155
+ start_index=current_step_index,
1156
+ total_steps=total_steps,
1157
+ )
1158
+ current_step_index += len(bootstrap_steps)
738
1159
 
739
1160
  return {
740
1161
  "event": "proxmox_container_created",
@@ -755,6 +1176,212 @@ class CreateProxmoxContainerHandler(SyncHandler):
755
1176
  }
756
1177
 
757
1178
 
1179
+ class StartPortacodeServiceHandler(SyncHandler):
1180
+ """Start the Portacode service inside a newly created container."""
1181
+
1182
+ @property
1183
+ def command_name(self) -> str:
1184
+ return "start_portacode_service"
1185
+
1186
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1187
+ ctid = message.get("ctid")
1188
+ if not ctid:
1189
+ raise ValueError("ctid is required")
1190
+ try:
1191
+ vmid = int(ctid)
1192
+ except ValueError:
1193
+ raise ValueError("ctid must be an integer")
1194
+
1195
+ record = _read_container_record(vmid)
1196
+ user = record.get("username")
1197
+ password = record.get("password")
1198
+ if not user or not password:
1199
+ raise RuntimeError("Container credentials unavailable")
1200
+
1201
+ start_index = int(message.get("step_index", 1))
1202
+ total_steps = int(message.get("total_steps", start_index + 2))
1203
+ request_id = message.get("request_id")
1204
+
1205
+ auth_step_name = "setup_device_authentication"
1206
+ auth_label = "Setting up device authentication"
1207
+ _emit_progress_event(
1208
+ self,
1209
+ step_index=start_index,
1210
+ total_steps=total_steps,
1211
+ step_name=auth_step_name,
1212
+ step_label=auth_label,
1213
+ status="in_progress",
1214
+ message="Notifying the server of the new device…",
1215
+ phase="service",
1216
+ request_id=request_id,
1217
+ )
1218
+ _emit_progress_event(
1219
+ self,
1220
+ step_index=start_index,
1221
+ total_steps=total_steps,
1222
+ step_name=auth_step_name,
1223
+ step_label=auth_label,
1224
+ status="completed",
1225
+ message="Authentication metadata recorded.",
1226
+ phase="service",
1227
+ request_id=request_id,
1228
+ )
1229
+
1230
+ install_step = start_index + 1
1231
+ install_label = "Launching Portacode service"
1232
+ _emit_progress_event(
1233
+ self,
1234
+ step_index=install_step,
1235
+ total_steps=total_steps,
1236
+ step_name="launch_portacode_service",
1237
+ step_label=install_label,
1238
+ status="in_progress",
1239
+ message="Running sudo portacode service install…",
1240
+ phase="service",
1241
+ request_id=request_id,
1242
+ )
1243
+
1244
+ cmd = f"su - {user} -c 'sudo -S portacode service install'"
1245
+ res = _run_pct(vmid, cmd, input_text=password + "\n")
1246
+
1247
+ if res["returncode"] != 0:
1248
+ _emit_progress_event(
1249
+ self,
1250
+ step_index=install_step,
1251
+ total_steps=total_steps,
1252
+ step_name="launch_portacode_service",
1253
+ step_label=install_label,
1254
+ status="failed",
1255
+ message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
1256
+ phase="service",
1257
+ request_id=request_id,
1258
+ details={
1259
+ "stderr": res.get("stderr"),
1260
+ "stdout": res.get("stdout"),
1261
+ },
1262
+ )
1263
+ raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1264
+
1265
+ _emit_progress_event(
1266
+ self,
1267
+ step_index=install_step,
1268
+ total_steps=total_steps,
1269
+ step_name="launch_portacode_service",
1270
+ step_label=install_label,
1271
+ status="completed",
1272
+ message="Portacode service install finished.",
1273
+ phase="service",
1274
+ request_id=request_id,
1275
+ )
1276
+
1277
+ return {
1278
+ "event": "proxmox_service_started",
1279
+ "success": True,
1280
+ "message": "Portacode service install completed",
1281
+ "ctid": str(vmid),
1282
+ }
1283
+
1284
+
1285
+ class StartProxmoxContainerHandler(SyncHandler):
1286
+ """Start a managed container via the Proxmox API."""
1287
+
1288
+ @property
1289
+ def command_name(self) -> str:
1290
+ return "start_proxmox_container"
1291
+
1292
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1293
+ vmid = _parse_ctid(message)
1294
+ config = _ensure_infra_configured()
1295
+ proxmox = _connect_proxmox(config)
1296
+ node = _get_node_from_config(config)
1297
+ _ensure_container_managed(proxmox, node, vmid)
1298
+
1299
+ status, elapsed = _start_container(proxmox, node, vmid)
1300
+ _update_container_record(vmid, {"status": "running"})
1301
+
1302
+ infra = get_infra_snapshot()
1303
+ return {
1304
+ "event": "proxmox_container_action",
1305
+ "action": "start",
1306
+ "success": True,
1307
+ "ctid": str(vmid),
1308
+ "message": f"Started container {vmid} in {elapsed:.1f}s.",
1309
+ "details": {"exitstatus": status.get("exitstatus")},
1310
+ "status": status.get("status"),
1311
+ "infra": infra,
1312
+ }
1313
+
1314
+
1315
+ class StopProxmoxContainerHandler(SyncHandler):
1316
+ """Stop a managed container via the Proxmox API."""
1317
+
1318
+ @property
1319
+ def command_name(self) -> str:
1320
+ return "stop_proxmox_container"
1321
+
1322
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1323
+ vmid = _parse_ctid(message)
1324
+ config = _ensure_infra_configured()
1325
+ proxmox = _connect_proxmox(config)
1326
+ node = _get_node_from_config(config)
1327
+ _ensure_container_managed(proxmox, node, vmid)
1328
+
1329
+ status, elapsed = _stop_container(proxmox, node, vmid)
1330
+ final_status = status.get("status") or "stopped"
1331
+ _update_container_record(vmid, {"status": final_status})
1332
+
1333
+ infra = get_infra_snapshot()
1334
+ message_text = (
1335
+ f"Container {vmid} is already stopped."
1336
+ if final_status != "running" and elapsed == 0.0
1337
+ else f"Stopped container {vmid} in {elapsed:.1f}s."
1338
+ )
1339
+ return {
1340
+ "event": "proxmox_container_action",
1341
+ "action": "stop",
1342
+ "success": True,
1343
+ "ctid": str(vmid),
1344
+ "message": message_text,
1345
+ "details": {"exitstatus": status.get("exitstatus")},
1346
+ "status": final_status,
1347
+ "infra": infra,
1348
+ }
1349
+
1350
+
1351
+ class RemoveProxmoxContainerHandler(SyncHandler):
1352
+ """Delete a managed container via the Proxmox API."""
1353
+
1354
+ @property
1355
+ def command_name(self) -> str:
1356
+ return "remove_proxmox_container"
1357
+
1358
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1359
+ vmid = _parse_ctid(message)
1360
+ config = _ensure_infra_configured()
1361
+ proxmox = _connect_proxmox(config)
1362
+ node = _get_node_from_config(config)
1363
+ _ensure_container_managed(proxmox, node, vmid)
1364
+
1365
+ stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
1366
+ delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
1367
+ _remove_container_record(vmid)
1368
+
1369
+ infra = get_infra_snapshot()
1370
+ return {
1371
+ "event": "proxmox_container_action",
1372
+ "action": "remove",
1373
+ "success": True,
1374
+ "ctid": str(vmid),
1375
+ "message": f"Deleted container {vmid} in {delete_elapsed:.1f}s.",
1376
+ "details": {
1377
+ "stop_exitstatus": stop_status.get("exitstatus"),
1378
+ "delete_exitstatus": delete_status.get("exitstatus"),
1379
+ },
1380
+ "status": "deleted",
1381
+ "infra": infra,
1382
+ }
1383
+
1384
+
758
1385
  class ConfigureProxmoxInfraHandler(SyncHandler):
759
1386
  @property
760
1387
  def command_name(self) -> str:
@@ -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.12.dev1
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=KBZ1gEZmvt1J4XR1FpwaCleg3ayMUckV-yZYhnXlSCU,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=7tBYNEY8EBGAPIMT606BqeHnyMOQIZVlQYpH7me26LY,97962
18
+ portacode/connection/handlers/__init__.py,sha256=WSeBmi65GWFQPYt9M3E10rn0uZ_EPCJzNJOzSf2HZyw,2921
19
19
  portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
20
20
  portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
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=Sd25UGM7edWDTIiatZvA2Dh3-4NOzfIDmixG0Fz5m0U,51343
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.12.dev1.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.12.dev1.dist-info/METADATA,sha256=o7psDA6l4eoRJLFQipCS3XcgsqTLxNMf39xhh6qYJ00,13051
94
+ portacode-1.4.12.dev1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
95
+ portacode-1.4.12.dev1.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
96
+ portacode-1.4.12.dev1.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
97
+ portacode-1.4.12.dev1.dist-info/RECORD,,