portacode 1.4.12.dev1__py3-none-any.whl → 1.4.15.dev10__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.12.dev1'
32
- __version_tuple__ = version_tuple = (1, 4, 12, 'dev1')
31
+ __version__ = version = '1.4.15.dev10'
32
+ __version_tuple__ = version_tuple = (1, 4, 15, 'dev10')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -27,6 +27,10 @@ The Portacode server acts as a **routing middleman** between client sessions and
27
27
 
28
28
  - **`source_client_session`** (Server → Device): Server **adds this** when forwarding client commands to devices (so device knows which client sent the command and can target responses back). Clients never include this field.
29
29
 
30
+ ### Proxying infrastructure updates
31
+
32
+ Portacode infrastructure devices (like the proxmox host) can send events on behalf of the LXC Devices they manage. Such messages include the optional `on_behalf_of_device` field and the server silently replaces `device_id` with that child device before routing. The gateway enforces that the sender is the child’s `proxmox_parent` (via `Device.proxmox_parent`) so only the infrastructure owner can impersonate a child device. Messages that fail this check are dropped.
33
+
30
34
  This document describes the complete protocol for communicating with devices through the server, guiding app developers on how to get their client sessions to communicate with devices.
31
35
 
32
36
  ## Table of Contents
@@ -361,6 +365,9 @@ Creates a Portacode-managed LXC container, starts it, and bootstraps the Portaco
361
365
  * `username` (string, optional): OS user to provision (defaults to `svcuser`).
362
366
  * `password` (string, optional): Password for the user (used only during provisioning).
363
367
  * `ssh_key` (string, optional): SSH public key to add to the user.
368
+ * `device_id` (string, required): ID of the dashboard Device record that represents the container. The handler persists this value in the host metadata file so related events can always be correlated back to that Device.
369
+ * `device_public_key` (string, optional): PEM-encoded Portacode public key. When supplied together with `device_private_key` the handler injects the keypair, records the device metadata, and runs `portacode service install` automatically.
370
+ * `device_private_key` (string, optional): PEM-encoded private key that pairs with `device_public_key`. Both key fields must be present for the automatic service-install mode.
364
371
 
365
372
  **Responses:**
366
373
 
@@ -418,6 +425,9 @@ Emitted after a successful `create_proxmox_container` action. Contains the new c
418
425
  * `public_key` (string): Portacode public auth key created inside the new container.
419
426
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
420
427
  * `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
428
+ * `device_id` (string): Mirrors the dashboard Device ID supplied with `create_proxmox_container`. The handler records this value in the container metadata file so subsequent events can reference the same Device.
429
+ * `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device; only proxmox parents may include this field.
430
+ * `service_installed` (boolean): True when the handler already ran `portacode service install` (with a provided keypair); otherwise it remains False and the dashboard can call `start_portacode_service`.
421
431
 
422
432
  ### `proxmox_container_progress`
423
433
 
@@ -434,6 +444,7 @@ Sent intermittently while `create_proxmox_container` is executing so callers can
434
444
  * `message` (string): Short description of what is happening or why a failure occurred.
435
445
  * `details` (object, optional): Contains `attempt` (if retries were needed) and `error_summary` when a step fails.
436
446
  * `request_id` (string, optional): Mirrors the request ID from the incoming `create_proxmox_container` payload when available.
447
+ * `on_behalf_of_device` (string, optional): When present the proxmox device is reporting progress for the referenced dashboard device; the gateway verifies the proxmox node is the child’s `proxmox_parent` before routing the event.
437
448
 
438
449
  ### `start_portacode_service`
439
450
 
@@ -450,6 +461,7 @@ Runs `sudo portacode service install` inside the container after the dashboard h
450
461
  * Emits additional [`proxmox_container_progress`](#proxmox_container_progress-event) events to report the authentication and service-install steps.
451
462
  * On success, emits a [`proxmox_service_started`](#proxmox_service_started-event).
452
463
  * On failure, emits a generic [`error`](#error) event.
464
+ * When `create_proxmox_container` already provided a dashboard-generated keypair, the handler may have installed the service already, so this call is optional unless you need to re-run the install.
453
465
 
454
466
  ### `proxmox_service_started`
455
467
 
@@ -1171,6 +1183,8 @@ Emitted after a successful `create_proxmox_container` action to report the newly
1171
1183
  * `public_key` (string): Portacode public auth key discovered inside the container.
1172
1184
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
1173
1185
  * `setup_steps` (array[object]): Detailed bootstrap step reports including stdout/stderr, elapsed time, and pass/fail status.
1186
+ * `device_id` (string): Mirrors the device ID supplied with `create_proxmox_container` and persisted inside the host metadata file for this CT.
1187
+ * `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device.
1174
1188
 
1175
1189
  ### `proxmox_container_progress`
1176
1190
 
@@ -1187,6 +1201,7 @@ Sent continuously while `create_proxmox_container` runs so dashboards can show a
1187
1201
  * `message` (string): Short human-readable description of the action or failure.
1188
1202
  * `details` (object, optional): Contains `attempt` (when retries are used) and `error_summary` on failure.
1189
1203
  * `request_id` (string, optional): Mirrors the `create_proxmox_container` request when provided.
1204
+ * `on_behalf_of_device` (string, optional): Mirrors the child device ID when a proxmox host reports progress for that child; only proxmox parents can supply this field.
1190
1205
 
1191
1206
  ### `proxmox_container_action`
1192
1207
 
@@ -5,17 +5,20 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
+ import math
8
9
  import os
9
10
  import secrets
11
+ import shlex
10
12
  import shutil
11
13
  import stat
12
14
  import subprocess
13
15
  import sys
16
+ import tempfile
14
17
  import time
15
18
  import threading
16
19
  from datetime import datetime
17
20
  from pathlib import Path
18
- from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
21
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
19
22
 
20
23
  import platformdirs
21
24
 
@@ -44,6 +47,7 @@ UNIT_DIR = Path("/etc/systemd/system")
44
47
  _MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
45
48
  _MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
46
49
  _MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
50
+ _STARTUP_TEMPLATES_REFRESHED = False
47
51
 
48
52
  ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
49
53
 
@@ -60,6 +64,7 @@ def _emit_progress_event(
60
64
  phase: str,
61
65
  request_id: Optional[str],
62
66
  details: Optional[Dict[str, Any]] = None,
67
+ on_behalf_of_device: Optional[str] = None,
63
68
  ) -> None:
64
69
  loop = handler.context.get("event_loop")
65
70
  if not loop or loop.is_closed():
@@ -84,6 +89,8 @@ def _emit_progress_event(
84
89
  payload["request_id"] = request_id
85
90
  if details:
86
91
  payload["details"] = details
92
+ if on_behalf_of_device:
93
+ payload["on_behalf_of_device"] = str(on_behalf_of_device)
87
94
 
88
95
  future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
89
96
  future.add_done_callback(
@@ -173,6 +180,41 @@ def _list_templates(client: Any, node: str, storages: Iterable[Dict[str, Any]])
173
180
  return templates
174
181
 
175
182
 
183
+ def _build_proxmox_client_from_config(config: Dict[str, Any]):
184
+ user = config.get("user")
185
+ token_name = config.get("token_name")
186
+ token_value = config.get("token_value")
187
+ if not user or not token_name or not token_value:
188
+ raise RuntimeError("Proxmox API credentials are missing")
189
+ ProxmoxAPI = _ensure_proxmoxer()
190
+ return ProxmoxAPI(
191
+ config.get("host", DEFAULT_HOST),
192
+ user=user,
193
+ token_name=token_name,
194
+ token_value=token_value,
195
+ verify_ssl=config.get("verify_ssl", False),
196
+ timeout=30,
197
+ )
198
+
199
+
200
+ def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
201
+ global _STARTUP_TEMPLATES_REFRESHED
202
+ if _STARTUP_TEMPLATES_REFRESHED or not config or not config.get("token_value"):
203
+ _STARTUP_TEMPLATES_REFRESHED = True
204
+ return
205
+ try:
206
+ client = _build_proxmox_client_from_config(config)
207
+ node = config.get("node") or _pick_node(client)
208
+ storages = client.nodes(node).storage.get()
209
+ templates = _list_templates(client, node, storages)
210
+ if templates:
211
+ config["templates"] = templates
212
+ except Exception as exc:
213
+ logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
214
+ finally:
215
+ _STARTUP_TEMPLATES_REFRESHED = True
216
+
217
+
176
218
  def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
177
219
  candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
178
220
  if not candidates:
@@ -261,7 +303,10 @@ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
261
303
  apt = shutil.which("apt-get")
262
304
  if not apt:
263
305
  raise RuntimeError("dnsmasq is missing and apt-get unavailable to install it")
264
- _call_subprocess([apt, "update"], check=True)
306
+ update = _call_subprocess([apt, "update"], check=False)
307
+ if update.returncode not in (0, 100):
308
+ msg = update.stderr or update.stdout or f"exit status {update.returncode}"
309
+ raise RuntimeError(f"apt-get update failed: {msg}")
265
310
  _call_subprocess([apt, "install", "-y", "dnsmasq"], check=True)
266
311
  _write_bridge_config(bridge)
267
312
  _ensure_sysctl()
@@ -428,7 +473,12 @@ def _friendly_step_label(step_name: str) -> str:
428
473
  return normalized.capitalize()
429
474
 
430
475
 
431
- def _build_bootstrap_steps(user: str, password: str, ssh_key: str) -> List[Dict[str, Any]]:
476
+ def _build_bootstrap_steps(
477
+ user: str,
478
+ password: str,
479
+ ssh_key: str,
480
+ include_portacode_connect: bool = True,
481
+ ) -> List[Dict[str, Any]]:
432
482
  steps = [
433
483
  {
434
484
  "name": "apt_update",
@@ -465,11 +515,14 @@ def _build_bootstrap_steps(user: str, password: str, ssh_key: str) -> List[Dict[
465
515
  "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
516
  "retries": 0,
467
517
  })
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
- ])
518
+ steps.extend(
519
+ [
520
+ {"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
521
+ {"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
522
+ ]
523
+ )
524
+ if include_portacode_connect:
525
+ steps.append({"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30})
473
526
  return steps
474
527
 
475
528
 
@@ -590,7 +643,7 @@ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) ->
590
643
  hostname = (message.get("hostname") or "").strip()
591
644
  disk_gib = int(max(round(_validate_positive_number(message.get("disk_gib") or message.get("disk"), 3)), 1))
592
645
  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)
646
+ cpus = _validate_positive_number(message.get("cpus"), 0.2)
594
647
  storage = message.get("storage") or config.get("default_storage") or ""
595
648
  if not storage:
596
649
  raise ValueError("Storage pool could not be determined.")
@@ -679,6 +732,74 @@ def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
679
732
  return res
680
733
 
681
734
 
735
+ def _run_pct_exec(vmid: int, command: Sequence[str]) -> subprocess.CompletedProcess[str]:
736
+ return _call_subprocess(["pct", "exec", str(vmid), "--", *command])
737
+
738
+
739
+ def _run_pct_exec_check(vmid: int, command: Sequence[str]) -> subprocess.CompletedProcess[str]:
740
+ res = _run_pct_exec(vmid, command)
741
+ if res.returncode != 0:
742
+ raise RuntimeError(res.stderr or res.stdout or f"pct exec {' '.join(command)} failed")
743
+ return res
744
+
745
+
746
+ def _run_pct_push(vmid: int, src: str, dest: str) -> subprocess.CompletedProcess[str]:
747
+ return _call_subprocess(["pct", "push", str(vmid), src, dest])
748
+
749
+
750
+ def _push_bytes_to_container(
751
+ vmid: int, user: str, path: str, data: bytes, mode: int = 0o600
752
+ ) -> None:
753
+ logger.debug("Preparing to push %d bytes to container vmid=%s path=%s for user=%s", len(data), vmid, path, user)
754
+ tmp_path: Optional[str] = None
755
+ try:
756
+ parent = Path(path).parent
757
+ parent_str = parent.as_posix()
758
+ if parent_str not in {"", ".", "/"}:
759
+ _run_pct_exec_check(vmid, ["mkdir", "-p", parent_str])
760
+ _run_pct_exec_check(vmid, ["chown", "-R", f"{user}:{user}", parent_str])
761
+
762
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
763
+ tmp.write(data)
764
+ tmp.flush()
765
+ os.fsync(tmp.fileno())
766
+ tmp_path = tmp.name
767
+
768
+ push_res = _run_pct_push(vmid, tmp_path, path)
769
+ if push_res.returncode != 0:
770
+ raise RuntimeError(push_res.stderr or push_res.stdout or f"pct push returned {push_res.returncode}")
771
+
772
+ _run_pct_exec_check(vmid, ["chown", f"{user}:{user}", path])
773
+ _run_pct_exec_check(vmid, ["chmod", format(mode, "o"), path])
774
+ logger.debug("Successfully pushed %d bytes to vmid=%s path=%s", len(data), vmid, path)
775
+ except Exception as exc:
776
+ logger.error("Failed to write to container vmid=%s path=%s for user=%s: %s", vmid, path, user, exc)
777
+ raise
778
+ finally:
779
+ if tmp_path:
780
+ try:
781
+ os.remove(tmp_path)
782
+ except OSError as cleanup_exc:
783
+ logger.warning("Failed to remove temporary file %s: %s", tmp_path, cleanup_exc)
784
+
785
+
786
+ def _resolve_portacode_key_dir(vmid: int, user: str) -> str:
787
+ data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
788
+ data_home = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
789
+ portacode_dir = f"{data_home}/portacode"
790
+ _run_pct_exec_check(vmid, ["mkdir", "-p", portacode_dir])
791
+ _run_pct_exec_check(vmid, ["chown", "-R", f"{user}:{user}", portacode_dir])
792
+ return f"{portacode_dir}/keys"
793
+
794
+
795
+ def _deploy_device_keypair(vmid: int, user: str, private_key: str, public_key: str) -> None:
796
+ key_dir = _resolve_portacode_key_dir(vmid, user)
797
+ priv_path = f"{key_dir}/id_portacode"
798
+ pub_path = f"{key_dir}/id_portacode.pub"
799
+ _push_bytes_to_container(vmid, user, priv_path, private_key.encode(), mode=0o600)
800
+ _push_bytes_to_container(vmid, user, pub_path, public_key.encode(), mode=0o644)
801
+
802
+
682
803
  def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
683
804
  cmd = ["pct", "exec", str(vmid), "--", "bash", "-lc", f"su - {user} -c 'portacode connect'"]
684
805
  proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@@ -702,15 +823,22 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
702
823
 
703
824
  last_pub = last_priv = None
704
825
  stable = 0
826
+ history: List[Dict[str, Any]] = []
827
+
828
+ process_exited = False
829
+ exit_out = exit_err = ""
705
830
  while time.time() - start < timeout_s:
706
831
  if proc.poll() is not None:
707
- out, err = proc.communicate(timeout=1)
708
- return {
709
- "ok": False,
710
- "error": "portacode connect exited before keys were created",
711
- "stdout": (out or "").strip(),
712
- "stderr": (err or "").strip(),
713
- }
832
+ process_exited = True
833
+ exit_out, exit_err = proc.communicate(timeout=1)
834
+ history.append(
835
+ {
836
+ "timestamp_s": round(time.time() - start, 2),
837
+ "status": "process_exited",
838
+ "returncode": proc.returncode,
839
+ }
840
+ )
841
+ break
714
842
  pub_size = file_size(pub_path)
715
843
  priv_size = file_size(priv_path)
716
844
  if pub_size and priv_size:
@@ -720,21 +848,60 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
720
848
  stable = 0
721
849
  last_pub, last_priv = pub_size, priv_size
722
850
  if stable >= 1:
851
+ history.append(
852
+ {
853
+ "timestamp_s": round(time.time() - start, 2),
854
+ "pub_size": pub_size,
855
+ "priv_size": priv_size,
856
+ "stable": stable,
857
+ }
858
+ )
723
859
  break
860
+ history.append(
861
+ {
862
+ "timestamp_s": round(time.time() - start, 2),
863
+ "pub_size": pub_size,
864
+ "priv_size": priv_size,
865
+ "stable": stable,
866
+ }
867
+ )
724
868
  time.sleep(1)
725
869
 
726
- if stable < 1:
870
+ final_pub = file_size(pub_path)
871
+ final_priv = file_size(priv_path)
872
+ if final_pub and final_priv:
873
+ key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
874
+ if not process_exited:
875
+ proc.terminate()
876
+ try:
877
+ proc.wait(timeout=3)
878
+ except subprocess.TimeoutExpired:
879
+ proc.kill()
880
+ return {
881
+ "ok": True,
882
+ "public_key": key_res["stdout"].strip(),
883
+ "history": history,
884
+ }
885
+
886
+ if not process_exited:
727
887
  proc.terminate()
728
888
  try:
729
889
  proc.wait(timeout=3)
730
890
  except subprocess.TimeoutExpired:
731
891
  proc.kill()
732
- out, err = proc.communicate(timeout=1)
892
+ exit_out, exit_err = proc.communicate(timeout=1)
893
+ history.append(
894
+ {
895
+ "timestamp_s": round(time.time() - start, 2),
896
+ "status": "timeout_waiting_for_keys",
897
+ }
898
+ )
733
899
  return {
734
900
  "ok": False,
735
901
  "error": "timed out waiting for portacode key files",
736
- "stdout": (out or "").strip(),
737
- "stderr": (err or "").strip(),
902
+ "stdout": (exit_out or "").strip(),
903
+ "stderr": (exit_err or "").strip(),
904
+ "history": history,
738
905
  }
739
906
 
740
907
  proc.terminate()
@@ -747,6 +914,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
747
914
  return {
748
915
  "ok": True,
749
916
  "public_key": key_res["stdout"].strip(),
917
+ "history": history,
750
918
  }
751
919
 
752
920
 
@@ -833,6 +1001,7 @@ def _bootstrap_portacode(
833
1001
  progress_callback: Optional[ProgressCallback] = None,
834
1002
  start_index: int = 1,
835
1003
  total_steps: Optional[int] = None,
1004
+ default_public_key: Optional[str] = None,
836
1005
  ) -> Tuple[str, List[Dict[str, Any]]]:
837
1006
  actual_steps = steps if steps is not None else _build_bootstrap_steps(user, password, ssh_key)
838
1007
  results, ok = _run_setup_steps(
@@ -844,9 +1013,33 @@ def _bootstrap_portacode(
844
1013
  total_steps=total_steps,
845
1014
  )
846
1015
  if not ok:
1016
+ details = results[-1] if results else {}
1017
+ summary = details.get("error_summary") or details.get("stderr") or details.get("stdout") or details.get("name")
1018
+ history = details.get("history")
1019
+ history_snippet = ""
1020
+ if isinstance(history, list) and history:
1021
+ history_snippet = f" history={history[-3:]}"
1022
+ command = details.get("cmd")
1023
+ command_text = ""
1024
+ if command:
1025
+ if isinstance(command, (list, tuple)):
1026
+ command_text = shlex.join(str(entry) for entry in command)
1027
+ else:
1028
+ command_text = str(command)
1029
+ command_suffix = f" command={command_text}" if command_text else ""
1030
+ if summary:
1031
+ logger.warning(
1032
+ "Portacode bootstrap failure summary=%s%s%s",
1033
+ summary,
1034
+ f" history_len={len(history)}" if history else "",
1035
+ f" command={command_text}" if command_text else "",
1036
+ )
1037
+ raise RuntimeError(
1038
+ f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
1039
+ )
847
1040
  raise RuntimeError("Portacode bootstrap steps failed.")
848
1041
  key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
849
- public_key = key_step.get("public_key") if key_step else None
1042
+ public_key = key_step.get("public_key") if key_step else default_public_key
850
1043
  if not public_key:
851
1044
  raise RuntimeError("Portacode connect did not return a public key.")
852
1045
  return public_key, results
@@ -861,6 +1054,7 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
861
1054
  }
862
1055
  if not config:
863
1056
  return {"configured": False, "network": base_network}
1057
+ _ensure_templates_refreshed_on_startup(config)
864
1058
  return {
865
1059
  "configured": True,
866
1060
  "host": config.get("host"),
@@ -895,17 +1089,13 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
895
1089
  network = _ensure_bridge()
896
1090
  # Wait for network convergence before validating connectivity
897
1091
  time.sleep(2)
898
- if _verify_connectivity():
899
- network["health"] = "healthy"
900
- else:
901
- network = {"applied": False, "bridge": DEFAULT_BRIDGE, "message": "Connectivity check failed; bridge reverted"}
902
- _revert_bridge()
903
- except PermissionError as exc:
904
- network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
905
- logger.warning("Bridge setup skipped: %s", exc)
906
- except Exception as exc: # pragma: no cover - best effort
907
- network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
908
- logger.warning("Bridge setup failed: %s", exc)
1092
+ if not _verify_connectivity():
1093
+ raise RuntimeError("Connectivity check failed; bridge reverted")
1094
+ network["health"] = "healthy"
1095
+ except Exception as exc:
1096
+ logger.warning("Bridge setup failed; reverting previous changes: %s", exc)
1097
+ _revert_bridge()
1098
+ raise
909
1099
  config = {
910
1100
  "host": DEFAULT_HOST,
911
1101
  "node": node,
@@ -968,8 +1158,8 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
968
1158
  rootfs=rootfs,
969
1159
  memory=int(payload["ram_mib"]),
970
1160
  swap=int(payload.get("swap_mb", 0)),
971
- cores=int(payload.get("cpus", 1)),
972
- cpuunits=int(payload.get("cpuunits", 256)),
1161
+ cores=max(int(payload.get("cores", 1)), 1),
1162
+ cpulimit=float(payload.get("cpulimit", payload.get("cpus", 1))),
973
1163
  net0=payload["net0"],
974
1164
  unprivileged=int(payload.get("unprivileged", 1)),
975
1165
  description=payload.get("description", MANAGED_MARKER),
@@ -992,8 +1182,20 @@ class CreateProxmoxContainerHandler(SyncHandler):
992
1182
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
993
1183
  logger.info("create_proxmox_container command received")
994
1184
  request_id = message.get("request_id")
1185
+ raw_device_id = message.get("device_id")
1186
+ device_id = str(raw_device_id or "").strip()
1187
+ if not device_id:
1188
+ raise ValueError("device_id is required to create a container")
1189
+ device_public_key = (message.get("device_public_key") or "").strip()
1190
+ device_private_key = (message.get("device_private_key") or "").strip()
1191
+ has_device_keypair = bool(device_public_key and device_private_key)
995
1192
  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)
1193
+ bootstrap_steps = _build_bootstrap_steps(
1194
+ bootstrap_user,
1195
+ bootstrap_password,
1196
+ bootstrap_ssh_key,
1197
+ include_portacode_connect=not has_device_keypair,
1198
+ )
997
1199
  total_steps = 3 + len(bootstrap_steps) + 2
998
1200
  current_step_index = 1
999
1201
 
@@ -1015,6 +1217,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1015
1217
  message=start_message,
1016
1218
  phase="lifecycle",
1017
1219
  request_id=request_id,
1220
+ on_behalf_of_device=device_id,
1018
1221
  )
1019
1222
  try:
1020
1223
  result = action()
@@ -1030,6 +1233,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1030
1233
  phase="lifecycle",
1031
1234
  request_id=request_id,
1032
1235
  details={"error": str(exc)},
1236
+ on_behalf_of_device=device_id,
1033
1237
  )
1034
1238
  raise
1035
1239
  _emit_progress_event(
@@ -1042,6 +1246,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1042
1246
  message=success_message,
1043
1247
  phase="lifecycle",
1044
1248
  request_id=request_id,
1249
+ on_behalf_of_device=device_id,
1045
1250
  )
1046
1251
  current_step_index += 1
1047
1252
  return result
@@ -1068,7 +1273,8 @@ class CreateProxmoxContainerHandler(SyncHandler):
1068
1273
  proxmox = _connect_proxmox(config)
1069
1274
  node = config.get("node") or DEFAULT_NODE_NAME
1070
1275
  payload = _build_container_payload(message, config)
1071
- payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
1276
+ payload["cpulimit"] = float(payload["cpus"])
1277
+ payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
1072
1278
  payload["memory"] = int(payload["ram_mib"])
1073
1279
  payload["node"] = node
1074
1280
  logger.debug(
@@ -1083,6 +1289,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1083
1289
  payload["vmid"] = vmid
1084
1290
  payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1085
1291
  payload["status"] = "creating"
1292
+ payload["device_id"] = device_id
1086
1293
  _write_container_record(vmid, payload)
1087
1294
  return proxmox, node, vmid, payload
1088
1295
 
@@ -1143,6 +1350,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1143
1350
  phase="bootstrap",
1144
1351
  request_id=request_id,
1145
1352
  details=details or None,
1353
+ on_behalf_of_device=device_id,
1146
1354
  )
1147
1355
 
1148
1356
  public_key, steps = _bootstrap_portacode(
@@ -1154,9 +1362,107 @@ class CreateProxmoxContainerHandler(SyncHandler):
1154
1362
  progress_callback=_bootstrap_progress_callback,
1155
1363
  start_index=current_step_index,
1156
1364
  total_steps=total_steps,
1365
+ default_public_key=device_public_key if has_device_keypair else None,
1157
1366
  )
1158
1367
  current_step_index += len(bootstrap_steps)
1159
1368
 
1369
+ service_installed = False
1370
+ if has_device_keypair:
1371
+ logger.info(
1372
+ "deploying dashboard-provided Portacode keypair (device_id=%s) into container %s",
1373
+ device_id,
1374
+ vmid,
1375
+ )
1376
+ _deploy_device_keypair(
1377
+ vmid,
1378
+ payload["username"],
1379
+ device_private_key,
1380
+ device_public_key,
1381
+ )
1382
+ service_installed = True
1383
+ service_start_index = current_step_index
1384
+
1385
+ auth_step_name = "setup_device_authentication"
1386
+ auth_label = "Setting up device authentication"
1387
+ _emit_progress_event(
1388
+ self,
1389
+ step_index=service_start_index,
1390
+ total_steps=total_steps,
1391
+ step_name=auth_step_name,
1392
+ step_label=auth_label,
1393
+ status="in_progress",
1394
+ message="Notifying the server of the new device…",
1395
+ phase="service",
1396
+ request_id=request_id,
1397
+ on_behalf_of_device=device_id,
1398
+ )
1399
+ _emit_progress_event(
1400
+ self,
1401
+ step_index=service_start_index,
1402
+ total_steps=total_steps,
1403
+ step_name=auth_step_name,
1404
+ step_label=auth_label,
1405
+ status="completed",
1406
+ message="Authentication metadata recorded.",
1407
+ phase="service",
1408
+ request_id=request_id,
1409
+ on_behalf_of_device=device_id,
1410
+ )
1411
+
1412
+ install_step = service_start_index + 1
1413
+ install_label = "Launching Portacode service"
1414
+ _emit_progress_event(
1415
+ self,
1416
+ step_index=install_step,
1417
+ total_steps=total_steps,
1418
+ step_name="launch_portacode_service",
1419
+ step_label=install_label,
1420
+ status="in_progress",
1421
+ message="Running sudo portacode service install…",
1422
+ phase="service",
1423
+ request_id=request_id,
1424
+ on_behalf_of_device=device_id,
1425
+ )
1426
+
1427
+ cmd = f"su - {payload['username']} -c 'sudo -S portacode service install'"
1428
+ res = _run_pct(vmid, cmd, input_text=payload["password"] + "\n")
1429
+
1430
+ if res["returncode"] != 0:
1431
+ _emit_progress_event(
1432
+ self,
1433
+ step_index=install_step,
1434
+ total_steps=total_steps,
1435
+ step_name="launch_portacode_service",
1436
+ step_label=install_label,
1437
+ status="failed",
1438
+ message=f"{install_label} failed: {res.get('stderr') or res.get('stdout')}",
1439
+ phase="service",
1440
+ request_id=request_id,
1441
+ details={
1442
+ "stderr": res.get("stderr"),
1443
+ "stdout": res.get("stdout"),
1444
+ },
1445
+ on_behalf_of_device=device_id,
1446
+ )
1447
+ raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1448
+
1449
+ _emit_progress_event(
1450
+ self,
1451
+ step_index=install_step,
1452
+ total_steps=total_steps,
1453
+ step_name="launch_portacode_service",
1454
+ step_label=install_label,
1455
+ status="completed",
1456
+ message="Portacode service install finished.",
1457
+ phase="service",
1458
+ request_id=request_id,
1459
+ on_behalf_of_device=device_id,
1460
+ )
1461
+
1462
+ logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
1463
+
1464
+ current_step_index += 2
1465
+
1160
1466
  return {
1161
1467
  "event": "proxmox_container_created",
1162
1468
  "success": True,
@@ -1173,6 +1479,9 @@ class CreateProxmoxContainerHandler(SyncHandler):
1173
1479
  "cpus": payload["cpus"],
1174
1480
  },
1175
1481
  "setup_steps": steps,
1482
+ "device_id": device_id,
1483
+ "on_behalf_of_device": device_id,
1484
+ "service_installed": service_installed,
1176
1485
  }
1177
1486
 
1178
1487
 
@@ -1197,6 +1506,9 @@ class StartPortacodeServiceHandler(SyncHandler):
1197
1506
  password = record.get("password")
1198
1507
  if not user or not password:
1199
1508
  raise RuntimeError("Container credentials unavailable")
1509
+ on_behalf_of_device = record.get("device_id")
1510
+ if on_behalf_of_device:
1511
+ on_behalf_of_device = str(on_behalf_of_device)
1200
1512
 
1201
1513
  start_index = int(message.get("step_index", 1))
1202
1514
  total_steps = int(message.get("total_steps", start_index + 2))
@@ -1214,6 +1526,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1214
1526
  message="Notifying the server of the new device…",
1215
1527
  phase="service",
1216
1528
  request_id=request_id,
1529
+ on_behalf_of_device=on_behalf_of_device,
1217
1530
  )
1218
1531
  _emit_progress_event(
1219
1532
  self,
@@ -1225,6 +1538,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1225
1538
  message="Authentication metadata recorded.",
1226
1539
  phase="service",
1227
1540
  request_id=request_id,
1541
+ on_behalf_of_device=on_behalf_of_device,
1228
1542
  )
1229
1543
 
1230
1544
  install_step = start_index + 1
@@ -1239,6 +1553,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1239
1553
  message="Running sudo portacode service install…",
1240
1554
  phase="service",
1241
1555
  request_id=request_id,
1556
+ on_behalf_of_device=on_behalf_of_device,
1242
1557
  )
1243
1558
 
1244
1559
  cmd = f"su - {user} -c 'sudo -S portacode service install'"
@@ -1259,6 +1574,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1259
1574
  "stderr": res.get("stderr"),
1260
1575
  "stdout": res.get("stdout"),
1261
1576
  },
1577
+ on_behalf_of_device=on_behalf_of_device,
1262
1578
  )
1263
1579
  raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1264
1580
 
@@ -1272,6 +1588,7 @@ class StartPortacodeServiceHandler(SyncHandler):
1272
1588
  message="Portacode service install finished.",
1273
1589
  phase="service",
1274
1590
  request_id=request_id,
1591
+ on_behalf_of_device=on_behalf_of_device,
1275
1592
  )
1276
1593
 
1277
1594
  return {
@@ -10,8 +10,9 @@ import platform
10
10
  import shutil
11
11
  import subprocess
12
12
  import threading
13
+ import time
13
14
  from pathlib import Path
14
- from typing import Any, Dict
15
+ from typing import Any, Dict, Optional
15
16
 
16
17
  from portacode import __version__
17
18
  import psutil
@@ -31,11 +32,26 @@ _cpu_percent = 0.0
31
32
  _cpu_thread = None
32
33
  _cpu_lock = threading.Lock()
33
34
 
35
+ # Cgroup v2 tracking
36
+ _CGROUP_ROOT = Path("/sys/fs/cgroup")
37
+ _cgroup_path: Optional[Path] = None
38
+ _cgroup_v2_supported: Optional[bool] = None
39
+ _CGROUP_CPU_STAT = "cpu.stat"
40
+ _CGROUP_CPU_MAX = "cpu.max"
41
+ _last_cgroup_usage: Optional[int] = None
42
+ _last_cgroup_time: Optional[float] = None
43
+
34
44
  def _cpu_monitor():
35
45
  """Background thread to update CPU usage every 5 seconds."""
36
46
  global _cpu_percent
37
47
  while True:
38
- _cpu_percent = psutil.cpu_percent(interval=5.0)
48
+ percent = _get_cgroup_cpu_percent()
49
+ if percent is None:
50
+ # Fall back to psutil when cgroup stats are not available yet.
51
+ percent = psutil.cpu_percent(interval=5.0)
52
+ else:
53
+ time.sleep(5.0)
54
+ _cpu_percent = percent
39
55
 
40
56
  def _ensure_cpu_thread():
41
57
  """Ensure CPU monitoring thread is running (singleton)."""
@@ -130,6 +146,119 @@ def _get_playwright_info() -> Dict[str, Any]:
130
146
  return result
131
147
 
132
148
 
149
+ def _resolve_cgroup_path() -> Path:
150
+ global _cgroup_path
151
+ if _cgroup_path is not None and _cgroup_path.exists():
152
+ return _cgroup_path
153
+ path = _CGROUP_ROOT
154
+ cgroup_file = Path("/proc/self/cgroup")
155
+ if cgroup_file.exists():
156
+ try:
157
+ contents = cgroup_file.read_text()
158
+ except OSError:
159
+ pass
160
+ else:
161
+ for line in contents.splitlines():
162
+ line = line.strip()
163
+ if not line:
164
+ continue
165
+ parts = line.split(":", 2)
166
+ if len(parts) < 3:
167
+ continue
168
+ rel_path = parts[-1].lstrip("/")
169
+ candidate = _CGROUP_ROOT / rel_path
170
+ # Fallback to root path if the relative path is empty
171
+ candidate = candidate if rel_path else _CGROUP_ROOT
172
+ if candidate.exists():
173
+ path = candidate
174
+ break
175
+ _cgroup_path = path
176
+ return _cgroup_path
177
+
178
+
179
+ def _cgroup_file(name: str) -> Path:
180
+ return _resolve_cgroup_path() / name
181
+
182
+
183
+ def _detect_cgroup_v2() -> bool:
184
+ global _cgroup_v2_supported
185
+ if _cgroup_v2_supported is not None:
186
+ return _cgroup_v2_supported
187
+ controllers = _cgroup_file("cgroup.controllers")
188
+ cpu_stat = _cgroup_file(_CGROUP_CPU_STAT)
189
+ _cgroup_v2_supported = controllers.exists() and cpu_stat.exists()
190
+ return _cgroup_v2_supported
191
+
192
+
193
+ def _read_cgroup_cpu_usage() -> Optional[int]:
194
+ path = _cgroup_file(_CGROUP_CPU_STAT)
195
+ try:
196
+ data = path.read_text()
197
+ except (OSError, UnicodeDecodeError):
198
+ return None
199
+ for line in data.splitlines():
200
+ parts = line.strip().split()
201
+ if len(parts) >= 2 and parts[0] == "usage_usec":
202
+ try:
203
+ return int(parts[1])
204
+ except ValueError:
205
+ return None
206
+ return None
207
+
208
+
209
+ def _read_cgroup_cpu_limit() -> Optional[float]:
210
+ """Return the allowed CPU (cores) for this cgroup, if limited."""
211
+ path = _cgroup_file(_CGROUP_CPU_MAX)
212
+ if not path.exists():
213
+ return None
214
+ try:
215
+ data = path.read_text().strip()
216
+ except (OSError, UnicodeDecodeError):
217
+ return None
218
+ parts = data.split()
219
+ if len(parts) < 2:
220
+ return None
221
+ quota, period = parts[0], parts[1]
222
+ if quota == "max":
223
+ return None
224
+ try:
225
+ quota_value = int(quota)
226
+ period_value = int(period)
227
+ except ValueError:
228
+ return None
229
+ if period_value <= 0:
230
+ return None
231
+ return quota_value / period_value
232
+
233
+
234
+ def _get_cgroup_cpu_percent() -> Optional[float]:
235
+ if not _detect_cgroup_v2():
236
+ return None
237
+ usage = _read_cgroup_cpu_usage()
238
+ if usage is None:
239
+ return None
240
+ now = time.monotonic()
241
+ global _last_cgroup_usage, _last_cgroup_time
242
+ prev_usage = _last_cgroup_usage
243
+ prev_time = _last_cgroup_time
244
+ _last_cgroup_usage = usage
245
+ _last_cgroup_time = now
246
+ if prev_usage is None or prev_time is None:
247
+ return None
248
+ delta_usage = usage - prev_usage
249
+ delta_time = now - prev_time
250
+ if delta_time <= 0 or delta_usage < 0:
251
+ return None
252
+ cpu_ratio = (delta_usage / 1_000_000) / delta_time
253
+ limit_cpus = _read_cgroup_cpu_limit()
254
+ if limit_cpus and limit_cpus > 0:
255
+ percent = (cpu_ratio / limit_cpus) * 100.0
256
+ else:
257
+ cpu_count = psutil.cpu_count(logical=True) or 1
258
+ percent = (cpu_ratio / cpu_count) * 100.0
259
+ return max(0.0, min(percent, 100.0))
260
+
261
+
133
262
  def _run_probe_command(cmd: list[str]) -> str | None:
134
263
  try:
135
264
  result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=3)
@@ -0,0 +1,13 @@
1
+ from unittest import TestCase
2
+
3
+ from portacode.connection.handlers.proxmox_infra import _build_bootstrap_steps
4
+
5
+
6
+ class ProxmoxInfraHandlerTests(TestCase):
7
+ def test_build_bootstrap_steps_includes_portacode_connect_by_default(self):
8
+ steps = _build_bootstrap_steps("svcuser", "pass", "", include_portacode_connect=True)
9
+ self.assertTrue(any(step.get("name") == "portacode_connect" for step in steps))
10
+
11
+ def test_build_bootstrap_steps_skips_portacode_connect_when_requested(self):
12
+ steps = _build_bootstrap_steps("svcuser", "pass", "", include_portacode_connect=False)
13
+ self.assertFalse(any(step.get("name") == "portacode_connect" for step in steps))
@@ -56,6 +56,9 @@ from .handlers import (
56
56
  CreateProxmoxContainerHandler,
57
57
  RevertProxmoxInfraHandler,
58
58
  StartPortacodeServiceHandler,
59
+ StartProxmoxContainerHandler,
60
+ StopProxmoxContainerHandler,
61
+ RemoveProxmoxContainerHandler,
59
62
  )
60
63
  from .handlers.project_aware_file_handlers import (
61
64
  ProjectAwareFileWriteHandler,
@@ -480,6 +483,9 @@ class TerminalManager:
480
483
  self._command_registry.register(ConfigureProxmoxInfraHandler)
481
484
  self._command_registry.register(CreateProxmoxContainerHandler)
482
485
  self._command_registry.register(StartPortacodeServiceHandler)
486
+ self._command_registry.register(StartProxmoxContainerHandler)
487
+ self._command_registry.register(StopProxmoxContainerHandler)
488
+ self._command_registry.register(RemoveProxmoxContainerHandler)
483
489
  self._command_registry.register(RevertProxmoxInfraHandler)
484
490
  self._command_registry.register(UpdatePortacodeHandler)
485
491
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.12.dev1
3
+ Version: 1.4.15.dev10
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=KBZ1gEZmvt1J4XR1FpwaCleg3ayMUckV-yZYhnXlSCU,719
4
+ portacode/_version.py,sha256=OsU3LmSVwSb16YuRZZoOGhx4fmAppA2W-vOQo60j6hQ,721
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,9 +12,9 @@ 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=07wxG_55JMy3yQ9TXCBldW9h43qCW3U8rv2yzGMx4FM,44757
15
+ portacode/connection/terminal.py,sha256=oyLPOVLPlUuN_eRvHPGazB51yi8W8JEF3oOEYxucGTE,45069
16
16
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
17
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=7tBYNEY8EBGAPIMT606BqeHnyMOQIZVlQYpH7me26LY,97962
17
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=OtObmoVAXlf0uU1HidTWNmyJYBS1Yl6Rpgyh6TOTjUQ,100590
18
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
@@ -22,12 +22,13 @@ portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZb
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=Sd25UGM7edWDTIiatZvA2Dh3-4NOzfIDmixG0Fz5m0U,51343
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=5DAHhzbegCI0fKdekP8Or6jrGZptmthLdq-RMtOPZDc,63843
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=AKh7IbwptlLYrbSw5f-DHigvlaKHsg9lDP-lkAUm8cE,10755
28
+ portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
29
29
  portacode/connection/handlers/tab_factory.py,sha256=yn93h6GASjD1VpvW1oqpax3EpoT0r7r97zFXxML1wdA,16173
30
30
  portacode/connection/handlers/terminal_handlers.py,sha256=HRwHW1GiqG1NtHVEqXHKaYkFfQEzCDDH6YIlHcb4XD8,11866
31
+ portacode/connection/handlers/test_proxmox_infra.py,sha256=d6iBB4pwAqWWdEGRayLxDEexqCElbGZDJlCB4bXba24,682
31
32
  portacode/connection/handlers/update_handler.py,sha256=f2K4LmG4sHJZ3LahzzoRtHBULTKkPUNwuyhwuAAg3RA,2054
32
33
  portacode/connection/handlers/project_state/README.md,sha256=trdd4ig6ungmwH5SpbSLfyxbL-QgPlGNU-_XrMEiXtw,10114
33
34
  portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL8r_wVs5Tlt6B9J7yQH6yQUt7gc,2541
@@ -64,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
64
65
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
65
66
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
66
67
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
67
- portacode-1.4.12.dev1.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.15.dev10.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
69
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
69
70
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
70
71
  test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
@@ -90,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
90
91
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
91
92
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
92
93
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
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,,
94
+ portacode-1.4.15.dev10.dist-info/METADATA,sha256=FZkeltpJrx27P2fnujDDrYsinIFjnm1VsnnqTSVBk1g,13052
95
+ portacode-1.4.15.dev10.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
+ portacode-1.4.15.dev10.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.15.dev10.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.15.dev10.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5