portacode 1.3.32__py3-none-any.whl → 1.4.11.dev5__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.

Files changed (56) hide show
  1. portacode/_version.py +2 -2
  2. portacode/cli.py +158 -14
  3. portacode/connection/client.py +127 -8
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +370 -4
  5. portacode/connection/handlers/__init__.py +16 -1
  6. portacode/connection/handlers/diff_handlers.py +603 -0
  7. portacode/connection/handlers/file_handlers.py +674 -17
  8. portacode/connection/handlers/project_aware_file_handlers.py +11 -0
  9. portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
  10. portacode/connection/handlers/project_state/git_manager.py +139 -572
  11. portacode/connection/handlers/project_state/handlers.py +28 -14
  12. portacode/connection/handlers/project_state/manager.py +226 -101
  13. portacode/connection/handlers/proxmox_infra.py +790 -0
  14. portacode/connection/handlers/session.py +465 -84
  15. portacode/connection/handlers/system_handlers.py +181 -8
  16. portacode/connection/handlers/tab_factory.py +1 -47
  17. portacode/connection/handlers/update_handler.py +61 -0
  18. portacode/connection/terminal.py +55 -10
  19. portacode/keypair.py +63 -1
  20. portacode/link_capture/__init__.py +38 -0
  21. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  22. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  23. portacode/link_capture/bin/elinks +3 -0
  24. portacode/link_capture/bin/gio-open +3 -0
  25. portacode/link_capture/bin/gnome-open +3 -0
  26. portacode/link_capture/bin/gvfs-open +3 -0
  27. portacode/link_capture/bin/kde-open +3 -0
  28. portacode/link_capture/bin/kfmclient +3 -0
  29. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  30. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  31. portacode/link_capture/bin/links +3 -0
  32. portacode/link_capture/bin/links2 +3 -0
  33. portacode/link_capture/bin/lynx +3 -0
  34. portacode/link_capture/bin/mate-open +3 -0
  35. portacode/link_capture/bin/netsurf +3 -0
  36. portacode/link_capture/bin/sensible-browser +3 -0
  37. portacode/link_capture/bin/w3m +3 -0
  38. portacode/link_capture/bin/x-www-browser +3 -0
  39. portacode/link_capture/bin/xdg-open +3 -0
  40. portacode/pairing.py +103 -0
  41. portacode/static/js/utils/ntp-clock.js +170 -79
  42. portacode/utils/diff_apply.py +456 -0
  43. portacode/utils/diff_renderer.py +371 -0
  44. portacode/utils/ntp_clock.py +45 -131
  45. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/METADATA +71 -3
  46. portacode-1.4.11.dev5.dist-info/RECORD +97 -0
  47. test_modules/test_device_online.py +1 -1
  48. test_modules/test_login_flow.py +8 -4
  49. test_modules/test_play_store_screenshots.py +294 -0
  50. testing_framework/.env.example +4 -1
  51. testing_framework/core/playwright_manager.py +63 -9
  52. portacode-1.3.32.dist-info/RECORD +0 -70
  53. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/WHEEL +0 -0
  54. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/entry_points.txt +0 -0
  55. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/licenses/LICENSE +0 -0
  56. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,790 @@
1
+ """Proxmox infrastructure configuration handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import stat
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
16
+
17
+ import platformdirs
18
+
19
+ from .base import SyncHandler
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ CONFIG_DIR = Path(platformdirs.user_config_dir("portacode"))
24
+ CONFIG_PATH = CONFIG_DIR / "proxmox_infra.json"
25
+ REPO_ROOT = Path(__file__).resolve().parents[3]
26
+ NET_SETUP_SCRIPT = REPO_ROOT / "proxmox_management" / "net_setup.py"
27
+ CONTAINERS_DIR = CONFIG_DIR / "containers"
28
+ MANAGED_MARKER = "portacode-managed:true"
29
+
30
+ DEFAULT_HOST = "localhost"
31
+ DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
32
+ DEFAULT_BRIDGE = "vmbr1"
33
+ SUBNET_CIDR = "10.10.0.1/24"
34
+ BRIDGE_IP = SUBNET_CIDR.split("/", 1)[0]
35
+ DHCP_START = "10.10.0.100"
36
+ DHCP_END = "10.10.0.200"
37
+ DNS_SERVER = "1.1.1.1"
38
+ IFACES_PATH = Path("/etc/network/interfaces")
39
+ SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
40
+ UNIT_DIR = Path("/etc/systemd/system")
41
+
42
+
43
+ def _call_subprocess(cmd: List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
44
+ env = os.environ.copy()
45
+ env.setdefault("DEBIAN_FRONTEND", "noninteractive")
46
+ return subprocess.run(cmd, env=env, text=True, capture_output=True, **kwargs)
47
+
48
+
49
+ def _ensure_proxmoxer() -> Any:
50
+ try:
51
+ from proxmoxer import ProxmoxAPI # noqa: F401
52
+ except ModuleNotFoundError as exc:
53
+ python = sys.executable
54
+ logger.info("Proxmoxer missing; installing via pip")
55
+ try:
56
+ _call_subprocess([python, "-m", "pip", "install", "proxmoxer"], check=True)
57
+ except subprocess.CalledProcessError as pip_exc:
58
+ msg = pip_exc.stderr or pip_exc.stdout or str(pip_exc)
59
+ raise RuntimeError(f"Failed to install proxmoxer: {msg}") from pip_exc
60
+ from proxmoxer import ProxmoxAPI # noqa: F401
61
+ from proxmoxer import ProxmoxAPI
62
+ return ProxmoxAPI
63
+
64
+
65
+ def _parse_token(token_identifier: str) -> Tuple[str, str]:
66
+ identifier = token_identifier.strip()
67
+ if "!" not in identifier or "@" not in identifier:
68
+ raise ValueError("Expected API token in the form user@realm!tokenid")
69
+ user_part, token_name = identifier.split("!", 1)
70
+ user = user_part.strip()
71
+ token_name = token_name.strip()
72
+ if "@" not in user:
73
+ raise ValueError("API token missing user realm (user@realm)")
74
+ if not token_name:
75
+ raise ValueError("Token identifier missing token name")
76
+ return user, token_name
77
+
78
+
79
+ def _save_config(data: Dict[str, Any]) -> None:
80
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
81
+ tmp_path = CONFIG_PATH.with_suffix(".tmp")
82
+ tmp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
83
+ os.replace(tmp_path, CONFIG_PATH)
84
+ os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)
85
+
86
+
87
+ def _load_config() -> Dict[str, Any]:
88
+ if not CONFIG_PATH.exists():
89
+ return {}
90
+ try:
91
+ return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
92
+ except json.JSONDecodeError as exc:
93
+ logger.warning("Failed to parse Proxmox infra config: %s", exc)
94
+ return {}
95
+
96
+
97
+ def _pick_node(client: Any) -> str:
98
+ nodes = client.nodes().get()
99
+ for node in nodes:
100
+ if node.get("node") == DEFAULT_NODE_NAME:
101
+ return DEFAULT_NODE_NAME
102
+ return nodes[0].get("node") if nodes else DEFAULT_NODE_NAME
103
+
104
+
105
+ def _list_templates(client: Any, node: str, storages: Iterable[Dict[str, Any]]) -> List[str]:
106
+ templates: List[str] = []
107
+ for storage in storages:
108
+ storage_name = storage.get("storage")
109
+ if not storage_name:
110
+ continue
111
+ try:
112
+ items = client.nodes(node).storage(storage_name).content.get()
113
+ except Exception:
114
+ continue
115
+ for item in items:
116
+ if item.get("content") == "vztmpl" and item.get("volid"):
117
+ templates.append(item["volid"])
118
+ return templates
119
+
120
+
121
+ def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
122
+ candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
123
+ if not candidates:
124
+ candidates = [s for s in storages if "rootdir" in s.get("content", "")]
125
+ if not candidates:
126
+ return ""
127
+ candidates.sort(key=lambda entry: entry.get("avail", 0), reverse=True)
128
+ return candidates[0].get("storage", "")
129
+
130
+
131
+ def _write_bridge_config(bridge: str) -> None:
132
+ begin = f"# Portacode INFRA BEGIN {bridge}"
133
+ end = f"# Portacode INFRA END {bridge}"
134
+ current = IFACES_PATH.read_text(encoding="utf-8") if IFACES_PATH.exists() else ""
135
+ if begin in current:
136
+ return
137
+ block = f"""
138
+ {begin}
139
+ auto {bridge}
140
+ iface {bridge} inet static
141
+ address {SUBNET_CIDR}
142
+ bridge-ports none
143
+ bridge-stp off
144
+ bridge-fd 0
145
+ {end}
146
+
147
+ """
148
+ mode = "a" if IFACES_PATH.exists() else "w"
149
+ with open(IFACES_PATH, mode, encoding="utf-8") as fh:
150
+ if current and not current.endswith("\n"):
151
+ fh.write("\n")
152
+ fh.write(block)
153
+
154
+
155
+ def _ensure_sysctl() -> None:
156
+ SYSCTL_PATH.write_text("net.ipv4.ip_forward=1\n", encoding="utf-8")
157
+ _call_subprocess(["/sbin/sysctl", "-w", "net.ipv4.ip_forward=1"], check=True)
158
+
159
+
160
+ def _write_units(bridge: str) -> None:
161
+ nat_name = f"portacode-{bridge}-nat.service"
162
+ dns_name = f"portacode-{bridge}-dnsmasq.service"
163
+ nat = UNIT_DIR / nat_name
164
+ dns = UNIT_DIR / dns_name
165
+ nat.write_text(f"""[Unit]
166
+ Description=Portacode NAT for {bridge}
167
+ After=network-online.target
168
+ Wants=network-online.target
169
+
170
+ [Service]
171
+ Type=oneshot
172
+ RemainAfterExit=yes
173
+ ExecStart=/usr/sbin/iptables -t nat -A POSTROUTING -s {BRIDGE_IP}/24 -o vmbr0 -j MASQUERADE
174
+ ExecStart=/usr/sbin/iptables -A FORWARD -i {bridge} -o vmbr0 -j ACCEPT
175
+ ExecStart=/usr/sbin/iptables -A FORWARD -i vmbr0 -o {bridge} -m state --state RELATED,ESTABLISHED -j ACCEPT
176
+ ExecStop=/usr/sbin/iptables -t nat -D POSTROUTING -s {BRIDGE_IP}/24 -o vmbr0 -j MASQUERADE
177
+ ExecStop=/usr/sbin/iptables -D FORWARD -i {bridge} -o vmbr0 -j ACCEPT
178
+ ExecStop=/usr/sbin/iptables -D FORWARD -i vmbr0 -o {bridge} -m state --state RELATED,ESTABLISHED -j ACCEPT
179
+
180
+ [Install]
181
+ WantedBy=multi-user.target
182
+ """, encoding="utf-8")
183
+ dns.write_text(f"""[Unit]
184
+ Description=Portacode dnsmasq for {bridge}
185
+ After=network-online.target
186
+ Wants=network-online.target
187
+
188
+ [Service]
189
+ Type=simple
190
+ ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --interface={bridge} --bind-interfaces --listen-address={BRIDGE_IP} \
191
+ --port=0 --dhcp-range={DHCP_START},{DHCP_END},12h \
192
+ --dhcp-option=option:router,{BRIDGE_IP} \
193
+ --dhcp-option=option:dns-server,{DNS_SERVER} \
194
+ --conf-file=/dev/null --pid-file=/run/portacode_dnsmasq.pid --dhcp-leasefile=/var/lib/misc/portacode_dnsmasq.leases
195
+ Restart=always
196
+
197
+ [Install]
198
+ WantedBy=multi-user.target
199
+ """, encoding="utf-8")
200
+
201
+
202
+ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
203
+ if os.geteuid() != 0:
204
+ raise PermissionError("Bridge setup requires root privileges")
205
+ if not shutil.which("dnsmasq"):
206
+ apt = shutil.which("apt-get")
207
+ if not apt:
208
+ raise RuntimeError("dnsmasq is missing and apt-get unavailable to install it")
209
+ _call_subprocess([apt, "update"], check=True)
210
+ _call_subprocess([apt, "install", "-y", "dnsmasq"], check=True)
211
+ _write_bridge_config(bridge)
212
+ _ensure_sysctl()
213
+ _write_units(bridge)
214
+ _call_subprocess(["/bin/systemctl", "daemon-reload"], check=True)
215
+ nat_service = f"portacode-{bridge}-nat.service"
216
+ dns_service = f"portacode-{bridge}-dnsmasq.service"
217
+ _call_subprocess(["/bin/systemctl", "enable", "--now", nat_service, dns_service], check=True)
218
+ _call_subprocess(["/sbin/ifup", bridge], check=False)
219
+ return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
220
+
221
+
222
+ def _verify_connectivity(timeout: float = 5.0) -> bool:
223
+ try:
224
+ _call_subprocess(["/bin/ping", "-c", "2", "1.1.1.1"], check=True, timeout=timeout)
225
+ return True
226
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
227
+ return False
228
+
229
+
230
+ def _revert_bridge() -> None:
231
+ try:
232
+ if NET_SETUP_SCRIPT.exists():
233
+ _call_subprocess([sys.executable, str(NET_SETUP_SCRIPT), "revert"], check=True)
234
+ except Exception as exc:
235
+ logger.warning("Proxmox bridge revert failed: %s", exc)
236
+
237
+
238
+ def _ensure_containers_dir() -> None:
239
+ CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
240
+
241
+
242
+ def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
243
+ if storage_type in ("lvm", "lvmthin"):
244
+ return f"{storage}:{disk_gib}"
245
+ return f"{storage}:{disk_gib}G"
246
+
247
+
248
+ def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
249
+ for entry in storages:
250
+ if entry.get("storage") == storage_name:
251
+ return entry.get("type", "")
252
+ return ""
253
+
254
+
255
+ def _validate_positive_int(value: Any, default: int) -> int:
256
+ try:
257
+ candidate = int(value)
258
+ if candidate > 0:
259
+ return candidate
260
+ except Exception:
261
+ pass
262
+ return default
263
+
264
+
265
+ def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
266
+ start = time.time()
267
+ while True:
268
+ status = proxmox.nodes(node).tasks(upid).status.get()
269
+ if status.get("status") == "stopped":
270
+ return status, time.time() - start
271
+ time.sleep(1)
272
+
273
+
274
+ def _list_running_managed(proxmox: Any, node: str) -> List[Tuple[str, Dict[str, Any]]]:
275
+ entries = []
276
+ for ct in proxmox.nodes(node).lxc.get():
277
+ if ct.get("status") != "running":
278
+ continue
279
+ vmid = str(ct.get("vmid"))
280
+ cfg = proxmox.nodes(node).lxc(vmid).config.get()
281
+ if cfg and MANAGED_MARKER in (cfg.get("description") or ""):
282
+ entries.append((vmid, cfg))
283
+ return entries
284
+
285
+
286
+ def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
287
+ status = proxmox.nodes(node).lxc(vmid).status.current.get()
288
+ if status.get("status") == "running":
289
+ uptime = status.get("uptime", 0)
290
+ logger.info("Container %s already running (%ss)", vmid, uptime)
291
+ return status, 0.0
292
+
293
+ node_status = proxmox.nodes(node).status.get()
294
+ mem_total_mb = int(node_status.get("memory", {}).get("total", 0) // (1024**2))
295
+ cores_total = int(node_status.get("cpuinfo", {}).get("cores", 0))
296
+
297
+ running = _list_running_managed(proxmox, node)
298
+ used_mem_mb = sum(int(cfg.get("memory", 0)) for _, cfg in running)
299
+ used_cores = sum(int(cfg.get("cores", 0)) for _, cfg in running)
300
+
301
+ target_cfg = proxmox.nodes(node).lxc(vmid).config.get()
302
+ target_mem_mb = int(target_cfg.get("memory", 0))
303
+ target_cores = int(target_cfg.get("cores", 0))
304
+
305
+ if mem_total_mb and used_mem_mb + target_mem_mb > mem_total_mb:
306
+ raise RuntimeError("Not enough RAM to start this container safely.")
307
+ if cores_total and used_cores + target_cores > cores_total:
308
+ raise RuntimeError("Not enough CPU cores to start this container safely.")
309
+
310
+ upid = proxmox.nodes(node).lxc(vmid).status.start.post()
311
+ return _wait_for_task(proxmox, node, upid)
312
+
313
+
314
+ def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
315
+ _ensure_containers_dir()
316
+ path = CONTAINERS_DIR / f"ct-{vmid}.json"
317
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
318
+
319
+
320
+ def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
321
+ templates = config.get("templates") or []
322
+ default_template = templates[0] if templates else ""
323
+ template = message.get("template") or default_template
324
+ if not template:
325
+ raise ValueError("Container template is required.")
326
+
327
+ bridge = config.get("network", {}).get("bridge", DEFAULT_BRIDGE)
328
+ hostname = (message.get("hostname") or "").strip()
329
+ disk_gib = _validate_positive_int(message.get("disk_gib") or message.get("disk"), 32)
330
+ ram_mib = _validate_positive_int(message.get("ram_mib") or message.get("ram"), 2048)
331
+ cpus = _validate_positive_int(message.get("cpus"), 1)
332
+ storage = message.get("storage") or config.get("default_storage") or ""
333
+ if not storage:
334
+ raise ValueError("Storage pool could not be determined.")
335
+
336
+ user = (message.get("username") or "svcuser").strip() or "svcuser"
337
+ password = message.get("password") or ""
338
+ ssh_key = (message.get("ssh_key") or "").strip()
339
+
340
+ payload = {
341
+ "template": template,
342
+ "storage": storage,
343
+ "disk_gib": disk_gib,
344
+ "ram_mib": ram_mib,
345
+ "cpus": cpus,
346
+ "hostname": hostname,
347
+ "net0": f"name=eth0,bridge={bridge},ip=dhcp",
348
+ "unprivileged": 1,
349
+ "swap_mb": 0,
350
+ "username": user,
351
+ "password": password,
352
+ "ssh_public_key": ssh_key,
353
+ "description": MANAGED_MARKER,
354
+ }
355
+ return payload
356
+
357
+
358
+ def _connect_proxmox(config: Dict[str, Any]) -> Any:
359
+ ProxmoxAPI = _ensure_proxmoxer()
360
+ return ProxmoxAPI(
361
+ config.get("host", DEFAULT_HOST),
362
+ user=config.get("user"),
363
+ token_name=config.get("token_name"),
364
+ token_value=config.get("token_value"),
365
+ verify_ssl=config.get("verify_ssl", False),
366
+ timeout=60,
367
+ )
368
+
369
+
370
+ def _run_pct(vmid: int, cmd: str) -> Dict[str, Any]:
371
+ full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
372
+ start = time.time()
373
+ proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
374
+ return {
375
+ "cmd": cmd,
376
+ "returncode": proc.returncode,
377
+ "stdout": proc.stdout.strip(),
378
+ "stderr": proc.stderr.strip(),
379
+ "elapsed_s": round(time.time() - start, 2),
380
+ }
381
+
382
+
383
+ def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
384
+ res = _run_pct(vmid, cmd)
385
+ if res["returncode"] != 0:
386
+ raise RuntimeError(res.get("stderr") or res.get("stdout") or "command failed")
387
+ return res
388
+
389
+
390
+ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
391
+ cmd = ["pct", "exec", str(vmid), "--", "bash", "-lc", f"su - {user} -c 'portacode connect'"]
392
+ proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
393
+ start = time.time()
394
+
395
+ data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
396
+ data_dir = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
397
+ key_dir = f"{data_dir}/portacode/keys"
398
+ pub_path = f"{key_dir}/id_portacode.pub"
399
+ priv_path = f"{key_dir}/id_portacode"
400
+
401
+ def file_size(path: str) -> Optional[int]:
402
+ stat_cmd = f"su - {user} -c 'test -s {path} && stat -c %s {path}'"
403
+ res = _run_pct(vmid, stat_cmd)
404
+ if res["returncode"] != 0:
405
+ return None
406
+ try:
407
+ return int(res["stdout"].strip())
408
+ except ValueError:
409
+ return None
410
+
411
+ last_pub = last_priv = None
412
+ stable = 0
413
+ while time.time() - start < timeout_s:
414
+ if proc.poll() is not None:
415
+ out, err = proc.communicate(timeout=1)
416
+ return {
417
+ "ok": False,
418
+ "error": "portacode connect exited before keys were created",
419
+ "stdout": (out or "").strip(),
420
+ "stderr": (err or "").strip(),
421
+ }
422
+ pub_size = file_size(pub_path)
423
+ priv_size = file_size(priv_path)
424
+ if pub_size and priv_size:
425
+ if pub_size == last_pub and priv_size == last_priv:
426
+ stable += 1
427
+ else:
428
+ stable = 0
429
+ last_pub, last_priv = pub_size, priv_size
430
+ if stable >= 1:
431
+ break
432
+ time.sleep(1)
433
+
434
+ if stable < 1:
435
+ proc.terminate()
436
+ try:
437
+ proc.wait(timeout=3)
438
+ except subprocess.TimeoutExpired:
439
+ proc.kill()
440
+ out, err = proc.communicate(timeout=1)
441
+ return {
442
+ "ok": False,
443
+ "error": "timed out waiting for portacode key files",
444
+ "stdout": (out or "").strip(),
445
+ "stderr": (err or "").strip(),
446
+ }
447
+
448
+ proc.terminate()
449
+ try:
450
+ proc.wait(timeout=3)
451
+ except subprocess.TimeoutExpired:
452
+ proc.kill()
453
+
454
+ key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
455
+ return {
456
+ "ok": True,
457
+ "public_key": key_res["stdout"].strip(),
458
+ }
459
+
460
+
461
+ def _summarize_error(res: Dict[str, Any]) -> str:
462
+ text = f"{res.get('stdout','')}\n{res.get('stderr','')}"
463
+ if "No space left on device" in text:
464
+ return "Disk full inside container; increase rootfs or clean apt cache."
465
+ if "Unable to acquire the dpkg frontend lock" in text or "lock-frontend" in text:
466
+ return "Another apt/dpkg process is running; retry after it finishes."
467
+ if "Temporary failure resolving" in text or "Could not resolve" in text:
468
+ return "DNS/network resolution failed inside container."
469
+ if "Failed to fetch" in text:
470
+ return "Package repo fetch failed; check network and apt sources."
471
+ return "Command failed; see stdout/stderr for details."
472
+
473
+
474
+ def _run_setup_steps(vmid: int, steps: List[Dict[str, Any]], user: str) -> Tuple[List[Dict[str, Any]], bool]:
475
+ results: List[Dict[str, Any]] = []
476
+ for step in steps:
477
+ if step.get("type") == "portacode_connect":
478
+ res = _portacode_connect_and_read_key(vmid, user, timeout_s=step.get("timeout_s", 10))
479
+ res["name"] = step["name"]
480
+ results.append(res)
481
+ if not res.get("ok"):
482
+ return results, False
483
+ continue
484
+
485
+ attempts = 0
486
+ while True:
487
+ attempts += 1
488
+ res = _run_pct(vmid, step["cmd"])
489
+ res["name"] = step["name"]
490
+ res["attempt"] = attempts
491
+ if res["returncode"] != 0:
492
+ res["error_summary"] = _summarize_error(res)
493
+ results.append(res)
494
+ if res["returncode"] == 0:
495
+ break
496
+ retry_on = step.get("retry_on", [])
497
+ if attempts >= step.get("retries", 0) + 1:
498
+ return results, False
499
+ if any(tok in (res.get("stderr", "") + res.get("stdout", "")) for tok in retry_on):
500
+ time.sleep(step.get("retry_delay_s", 3))
501
+ continue
502
+ return results, False
503
+ return results, True
504
+
505
+
506
+ def _bootstrap_portacode(vmid: int, user: str, password: str, ssh_key: str) -> Tuple[str, List[Dict[str, Any]]]:
507
+ steps = [
508
+ {
509
+ "name": "apt_update",
510
+ "cmd": "apt-get update -y",
511
+ "retries": 4,
512
+ "retry_delay_s": 5,
513
+ "retry_on": [
514
+ "Temporary failure resolving",
515
+ "Could not resolve",
516
+ "Failed to fetch",
517
+ ],
518
+ },
519
+ {
520
+ "name": "install_deps",
521
+ "cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
522
+ "retries": 5,
523
+ "retry_delay_s": 5,
524
+ "retry_on": [
525
+ "lock-frontend",
526
+ "Unable to acquire the dpkg frontend lock",
527
+ "Temporary failure resolving",
528
+ "Could not resolve",
529
+ "Failed to fetch",
530
+ ],
531
+ },
532
+ {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
533
+ {"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
534
+ ]
535
+ if password:
536
+ steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
537
+ if ssh_key:
538
+ steps.append({
539
+ "name": "add_ssh_key",
540
+ "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
541
+ "retries": 0,
542
+ })
543
+ steps.extend([
544
+ {"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
545
+ {"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
546
+ {"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30},
547
+ ])
548
+
549
+ results, ok = _run_setup_steps(vmid, steps, user)
550
+ if not ok:
551
+ raise RuntimeError("Portacode bootstrap steps failed.")
552
+ key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
553
+ public_key = key_step.get("public_key") if key_step else None
554
+ if not public_key:
555
+ raise RuntimeError("Portacode connect did not return a public key.")
556
+ return public_key, results
557
+
558
+
559
+ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
560
+ network = config.get("network", {})
561
+ base_network = {
562
+ "applied": network.get("applied", False),
563
+ "message": network.get("message"),
564
+ "bridge": network.get("bridge", DEFAULT_BRIDGE),
565
+ }
566
+ if not config:
567
+ return {"configured": False, "network": base_network}
568
+ return {
569
+ "configured": True,
570
+ "host": config.get("host"),
571
+ "node": config.get("node"),
572
+ "user": config.get("user"),
573
+ "token_name": config.get("token_name"),
574
+ "default_storage": config.get("default_storage"),
575
+ "templates": config.get("templates") or [],
576
+ "last_verified": config.get("last_verified"),
577
+ "network": base_network,
578
+ }
579
+
580
+
581
+ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl: bool = False) -> Dict[str, Any]:
582
+ ProxmoxAPI = _ensure_proxmoxer()
583
+ user, token_name = _parse_token(token_identifier)
584
+ client = ProxmoxAPI(
585
+ DEFAULT_HOST,
586
+ user=user,
587
+ token_name=token_name,
588
+ token_value=token_value,
589
+ verify_ssl=verify_ssl,
590
+ timeout=30,
591
+ )
592
+ node = _pick_node(client)
593
+ status = client.nodes(node).status.get()
594
+ storages = client.nodes(node).storage.get()
595
+ default_storage = _pick_storage(storages)
596
+ templates = _list_templates(client, node, storages)
597
+ network: Dict[str, Any] = {}
598
+ try:
599
+ network = _ensure_bridge()
600
+ # Wait for network convergence before validating connectivity
601
+ time.sleep(2)
602
+ if _verify_connectivity():
603
+ network["health"] = "healthy"
604
+ else:
605
+ network = {"applied": False, "bridge": DEFAULT_BRIDGE, "message": "Connectivity check failed; bridge reverted"}
606
+ _revert_bridge()
607
+ except PermissionError as exc:
608
+ network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
609
+ logger.warning("Bridge setup skipped: %s", exc)
610
+ except Exception as exc: # pragma: no cover - best effort
611
+ network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
612
+ logger.warning("Bridge setup failed: %s", exc)
613
+ config = {
614
+ "host": DEFAULT_HOST,
615
+ "node": node,
616
+ "user": user,
617
+ "token_name": token_name,
618
+ "token_value": token_value,
619
+ "verify_ssl": verify_ssl,
620
+ "default_storage": default_storage,
621
+ "templates": templates,
622
+ "last_verified": datetime.utcnow().isoformat() + "Z",
623
+ "network": network,
624
+ "node_status": status,
625
+ }
626
+ _save_config(config)
627
+ snapshot = build_snapshot(config)
628
+ snapshot["node_status"] = status
629
+ return snapshot
630
+
631
+
632
+ def get_infra_snapshot() -> Dict[str, Any]:
633
+ config = _load_config()
634
+ snapshot = build_snapshot(config)
635
+ if config.get("node_status"):
636
+ snapshot["node_status"] = config["node_status"]
637
+ return snapshot
638
+
639
+
640
+ def revert_infrastructure() -> Dict[str, Any]:
641
+ _revert_bridge()
642
+ if CONFIG_PATH.exists():
643
+ CONFIG_PATH.unlink()
644
+ snapshot = build_snapshot({})
645
+ snapshot["network"] = snapshot.get("network", {})
646
+ snapshot["network"]["applied"] = False
647
+ snapshot["network"]["message"] = "Reverted to previous network state"
648
+ snapshot["network"]["bridge"] = DEFAULT_BRIDGE
649
+ return snapshot
650
+
651
+
652
+ def _allocate_vmid(proxmox: Any) -> int:
653
+ return int(proxmox.cluster.nextid.get())
654
+
655
+
656
+ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) -> Tuple[int, float]:
657
+ from proxmoxer.core import ResourceException
658
+
659
+ storage_type = _get_storage_type(proxmox.nodes(node).storage.get(), payload["storage"])
660
+ rootfs = _format_rootfs(payload["storage"], payload["disk_gib"], storage_type)
661
+ vmid = _allocate_vmid(proxmox)
662
+ if not payload.get("hostname"):
663
+ payload["hostname"] = f"ct{vmid}"
664
+ try:
665
+ upid = proxmox.nodes(node).lxc.create(
666
+ vmid=vmid,
667
+ hostname=payload["hostname"],
668
+ ostemplate=payload["template"],
669
+ rootfs=rootfs,
670
+ memory=int(payload["ram_mib"]),
671
+ swap=int(payload.get("swap_mb", 0)),
672
+ cores=int(payload.get("cpus", 1)),
673
+ cpuunits=int(payload.get("cpuunits", 256)),
674
+ net0=payload["net0"],
675
+ unprivileged=int(payload.get("unprivileged", 1)),
676
+ description=payload.get("description", MANAGED_MARKER),
677
+ password=payload.get("password") or None,
678
+ ssh_public_keys=payload.get("ssh_public_key") or None,
679
+ )
680
+ status, elapsed = _wait_for_task(proxmox, node, upid)
681
+ return vmid, elapsed
682
+ except ResourceException as exc:
683
+ raise RuntimeError(f"Failed to create container: {exc}") from exc
684
+
685
+
686
+ class CreateProxmoxContainerHandler(SyncHandler):
687
+ """Provision a new managed LXC container via the Proxmox API."""
688
+
689
+ @property
690
+ def command_name(self) -> str:
691
+ return "create_proxmox_container"
692
+
693
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
694
+ 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"],
720
+ )
721
+
722
+ try:
723
+ 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)
731
+
732
+ try:
733
+ _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
738
+
739
+ return {
740
+ "event": "proxmox_container_created",
741
+ "success": True,
742
+ "message": f"Container {vmid} is ready and Portacode key captured.",
743
+ "ctid": str(vmid),
744
+ "public_key": public_key,
745
+ "container": {
746
+ "vmid": vmid,
747
+ "hostname": payload["hostname"],
748
+ "template": payload["template"],
749
+ "storage": payload["storage"],
750
+ "disk_gib": payload["disk_gib"],
751
+ "ram_mib": payload["ram_mib"],
752
+ "cpus": payload["cpus"],
753
+ },
754
+ "setup_steps": steps,
755
+ }
756
+
757
+
758
+ class ConfigureProxmoxInfraHandler(SyncHandler):
759
+ @property
760
+ def command_name(self) -> str:
761
+ return "setup_proxmox_infra"
762
+
763
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
764
+ token_identifier = message.get("token_identifier")
765
+ token_value = message.get("token_value")
766
+ verify_ssl = bool(message.get("verify_ssl"))
767
+ if not token_identifier or not token_value:
768
+ raise ValueError("token_identifier and token_value are required")
769
+ snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
770
+ return {
771
+ "event": "proxmox_infra_configured",
772
+ "success": True,
773
+ "message": "Proxmox infrastructure configured",
774
+ "infra": snapshot,
775
+ }
776
+
777
+
778
+ class RevertProxmoxInfraHandler(SyncHandler):
779
+ @property
780
+ def command_name(self) -> str:
781
+ return "revert_proxmox_infra"
782
+
783
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
784
+ snapshot = revert_infrastructure()
785
+ return {
786
+ "event": "proxmox_infra_reverted",
787
+ "success": True,
788
+ "message": "Proxmox infrastructure configuration reverted",
789
+ "infra": snapshot,
790
+ }