portacode 1.4.11.dev0__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.

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.dev0'
32
- __version_tuple__ = version_tuple = (1, 4, 11, 'dev0')
31
+ __version__ = version = '1.4.11.dev5'
32
+ __version_tuple__ = version_tuple = (1, 4, 11, 'dev5')
33
33
 
34
34
  __commit_id__ = commit_id = None
portacode/cli.py CHANGED
@@ -405,6 +405,45 @@ def send_control(message: str, gateway: str | None) -> None: # noqa: D401 – C
405
405
  finally:
406
406
  await mgr.stop()
407
407
 
408
+ asyncio.run(_run())
409
+
410
+
411
+ @cli.command("revert_proxmox_infra")
412
+ @click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
413
+ def revert_proxmox_infra(gateway: str | None) -> None: # noqa: D401 – Click callback
414
+ """Revert the Proxmox infrastructure configuration stored on this device."""
415
+
416
+ target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
417
+
418
+ async def _run() -> None:
419
+ keypair = get_or_create_keypair()
420
+ mgr = ConnectionManager(target_gateway, keypair, debug=False)
421
+ await mgr.start()
422
+
423
+ for _ in range(20):
424
+ if mgr.mux is not None:
425
+ break
426
+ await asyncio.sleep(0.1)
427
+ if mgr.mux is None:
428
+ click.echo("Failed to initialise connection – aborting.")
429
+ await mgr.stop()
430
+ return
431
+
432
+ ctl = mgr.mux.get_channel(0)
433
+ await ctl.send({"cmd": "revert_proxmox_infra"})
434
+
435
+ try:
436
+ with click.progressbar(length=30, label="Waiting for revert response") as bar:
437
+ for _ in range(30):
438
+ try:
439
+ reply = await asyncio.wait_for(ctl.recv(), timeout=0.1)
440
+ click.echo(click.style("< " + json.dumps(reply, indent=2), fg="cyan"))
441
+ except asyncio.TimeoutError:
442
+ pass
443
+ bar.update(1)
444
+ finally:
445
+ await mgr.stop()
446
+
408
447
  asyncio.run(_run())
409
448
 
410
449
 
@@ -322,7 +322,7 @@ This action does not require any payload fields.
322
322
 
323
323
  ### `setup_proxmox_infra`
324
324
 
325
- Configures a Proxmox node for Portacode infrastructure usage (API token storage, default storage selection, and bridge setup). Handled by [`ConfigureProxmoxInfraHandler`](./proxmox_infra.py).
325
+ Configures a Proxmox node for Portacode infrastructure usage (API token validation, automatic storage/template detection, bridge/NAT setup, and connectivity verification). Handled by [`ConfigureProxmoxInfraHandler`](./proxmox_infra.py).
326
326
 
327
327
  **Payload Fields:**
328
328
 
@@ -333,7 +333,52 @@ Configures a Proxmox node for Portacode infrastructure usage (API token storage,
333
333
  **Responses:**
334
334
 
335
335
  * On success, the device will emit a [`proxmox_infra_configured`](#proxmox_infra_configured-event) event with the persisted infra snapshot.
336
- * On failure, the device will emit an [`error`](#error) event with details (e.g., permission issues, missing proxmoxer/dnsmasq, or missing root privileges).
336
+ * On failure, the device will emit an [`error`](#error) event with details (e.g., permission issues, missing proxmoxer/dnsmasq, missing root privileges, or failed network verification).
337
+
338
+ ### `revert_proxmox_infra`
339
+
340
+ Reverts the Proxmox infrastructure network changes and clears the stored API token. Handled by [`RevertProxmoxInfraHandler`](./proxmox_infra.py).
341
+
342
+ **Payload Fields:**
343
+
344
+ This action does not require any payload fields.
345
+
346
+ **Responses:**
347
+
348
+ * On success, the device will emit a [`proxmox_infra_reverted`](#proxmox_infra_reverted-event) event containing the cleared snapshot.
349
+
350
+ ### `create_proxmox_container`
351
+
352
+ Creates a Portacode-managed LXC container, starts it, and bootstraps the Portacode service by running the commands from [`proxmox_management/setup_portacode.py`](../../proxmox_management/setup_portacode.py). Handled by [`CreateProxmoxContainerHandler`](./proxmox_infra.py).
353
+
354
+ **Payload Fields:**
355
+
356
+ * `template` (string, required): Template identifier to use for the CT (e.g., `local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst`).
357
+ * `disk_gib` (integer, optional): Rootfs size in GiB (defaults to 32).
358
+ * `ram_mib` (integer, optional): Memory size in MiB (defaults to 2048).
359
+ * `cpus` (integer, optional): Number of CPU cores (defaults to 1).
360
+ * `hostname` (string, optional): Desired hostname inside the container; defaults to `ct<vmid>`.
361
+ * `username` (string, optional): OS user to provision (defaults to `svcuser`).
362
+ * `password` (string, optional): Password for the user (used only during provisioning).
363
+ * `ssh_key` (string, optional): SSH public key to add to the user.
364
+
365
+ **Responses:**
366
+
367
+ * On success, the device will emit a [`proxmox_container_created`](#proxmox_container_created-event) event that includes the Portacode auth key produced inside the container.
368
+ * On failure, the device will emit an [`error`](#error) event.
369
+
370
+ ### `proxmox_container_created`
371
+
372
+ Emitted after a successful `create_proxmox_container` action. Contains the new container ID, the Portacode public key produced inside the container, and the bootstrap logs.
373
+
374
+ **Event Fields:**
375
+
376
+ * `success` (boolean): True when the CT and Portacode bootstrap succeed.
377
+ * `message` (string): Human-readable summary (e.g., `Container 103 is ready`).
378
+ * `ctid` (string): The numeric CT ID.
379
+ * `public_key` (string): Portacode public auth key created inside the new container.
380
+ * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
381
+ * `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
337
382
 
338
383
  ### `clock_sync_request`
339
384
 
@@ -993,6 +1038,7 @@ Provides system information in response to a `system_info` action. Handled by [`
993
1038
  * `applied` (boolean): True when the bridge/NAT services were successfully configured.
994
1039
  * `message` (string|null): Informational text about the network setup attempt.
995
1040
  * `bridge` (string): The bridge interface configured (typically `vmbr1`).
1041
+ * `health` (string|null): `"healthy"` when the connectivity verification succeeded.
996
1042
  * `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
997
1043
  * `portacode_version` (string): Installed CLI version returned by `portacode.__version__`.
998
1044
 
@@ -1006,6 +1052,29 @@ Emitted after a successful `setup_proxmox_infra` action. The event reports the s
1006
1052
  * `message` (string): User-facing summary (e.g., "Proxmox infrastructure configured").
1007
1053
  * `infra` (object): Same snapshot described under [`system_info`](#system_info-event) `proxmox.infra`.
1008
1054
 
1055
+ ### `proxmox_infra_reverted`
1056
+
1057
+ Emitted after a successful `revert_proxmox_infra` action. Indicates the infra config is no longer present and the network was restored.
1058
+
1059
+ **Event Fields:**
1060
+
1061
+ * `success` (boolean): True when the revert completed.
1062
+ * `message` (string): Summary (e.g., "Proxmox infrastructure configuration reverted").
1063
+ * `infra` (object): Snapshot with `configured=false` (matching [`system_info`](#system_info-event) `proxmox.infra`).
1064
+
1065
+ ### `proxmox_container_created`
1066
+
1067
+ Emitted after a successful `create_proxmox_container` action to report the newly created CT, its Portacode public key, and the bootstrap logs.
1068
+
1069
+ **Event Fields:**
1070
+
1071
+ * `success` (boolean): True when the CT provisioning and `portacode connect` steps complete.
1072
+ * `message` (string): Human-readable summary (e.g., `Container 102 is ready`).
1073
+ * `ctid` (string): The container ID that was created.
1074
+ * `public_key` (string): Portacode public auth key discovered inside the container.
1075
+ * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
1076
+ * `setup_steps` (array[object]): Detailed bootstrap step reports including stdout/stderr, elapsed time, and pass/fail status.
1077
+
1009
1078
  ### <a name="clock_sync_response"></a>`clock_sync_response`
1010
1079
 
1011
1080
  Reply sent by the gateway immediately after receiving a `clock_sync_request`. Devices use this event plus the measured round-trip time to keep their local `ntp_clock` offset accurate.
@@ -14,6 +14,7 @@ from .terminal_handlers import (
14
14
  TerminalListHandler,
15
15
  )
16
16
  from .system_handlers import SystemInfoHandler
17
+ from .update_handler import UpdatePortacodeHandler
17
18
  from .file_handlers import (
18
19
  FileReadHandler,
19
20
  FileWriteHandler,
@@ -40,8 +41,11 @@ from .project_state_handlers import (
40
41
  ProjectStateGitRevertHandler,
41
42
  ProjectStateGitCommitHandler,
42
43
  )
43
- from .update_handler import UpdatePortacodeHandler
44
- from .proxmox_infra import ConfigureProxmoxInfraHandler
44
+ from .proxmox_infra import (
45
+ ConfigureProxmoxInfraHandler,
46
+ CreateProxmoxContainerHandler,
47
+ RevertProxmoxInfraHandler,
48
+ )
45
49
 
46
50
  __all__ = [
47
51
  "BaseHandler",
@@ -54,6 +58,7 @@ __all__ = [
54
58
  "TerminalListHandler",
55
59
  "SystemInfoHandler",
56
60
  "ConfigureProxmoxInfraHandler",
61
+ "CreateProxmoxContainerHandler",
57
62
  # File operation handlers (optional - register as needed)
58
63
  "FileReadHandler",
59
64
  "FileWriteHandler",
@@ -80,4 +85,5 @@ __all__ = [
80
85
  "ProjectStateGitRevertHandler",
81
86
  "ProjectStateGitCommitHandler",
82
87
  "UpdatePortacodeHandler",
88
+ "RevertProxmoxInfraHandler",
83
89
  ]
@@ -9,9 +9,10 @@ import shutil
9
9
  import stat
10
10
  import subprocess
11
11
  import sys
12
+ import time
12
13
  from datetime import datetime
13
14
  from pathlib import Path
14
- from typing import Any, Dict, Iterable, List, Tuple
15
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
15
16
 
16
17
  import platformdirs
17
18
 
@@ -21,6 +22,10 @@ logger = logging.getLogger(__name__)
21
22
 
22
23
  CONFIG_DIR = Path(platformdirs.user_config_dir("portacode"))
23
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"
24
29
 
25
30
  DEFAULT_HOST = "localhost"
26
31
  DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
@@ -214,10 +219,352 @@ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
214
219
  return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
215
220
 
216
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
+
217
559
  def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
218
- if not config:
219
- return {"configured": False}
220
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}
221
568
  return {
222
569
  "configured": True,
223
570
  "host": config.get("host"),
@@ -227,11 +574,7 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
227
574
  "default_storage": config.get("default_storage"),
228
575
  "templates": config.get("templates") or [],
229
576
  "last_verified": config.get("last_verified"),
230
- "network": {
231
- "applied": network.get("applied", False),
232
- "message": network.get("message"),
233
- "bridge": network.get("bridge", DEFAULT_BRIDGE),
234
- },
577
+ "network": base_network,
235
578
  }
236
579
 
237
580
 
@@ -254,6 +597,13 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
254
597
  network: Dict[str, Any] = {}
255
598
  try:
256
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()
257
607
  except PermissionError as exc:
258
608
  network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
259
609
  logger.warning("Bridge setup skipped: %s", exc)
@@ -287,6 +637,124 @@ def get_infra_snapshot() -> Dict[str, Any]:
287
637
  return snapshot
288
638
 
289
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
+
290
758
  class ConfigureProxmoxInfraHandler(SyncHandler):
291
759
  @property
292
760
  def command_name(self) -> str:
@@ -305,3 +773,18 @@ class ConfigureProxmoxInfraHandler(SyncHandler):
305
773
  "message": "Proxmox infrastructure configured",
306
774
  "infra": snapshot,
307
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
+ }
@@ -1,4 +1,5 @@
1
1
  """System command handlers."""
2
+ from __future__ import annotations
2
3
 
3
4
  import concurrent.futures
4
5
  import getpass
@@ -7,6 +8,7 @@ import logging
7
8
  import os
8
9
  import platform
9
10
  import shutil
11
+ import subprocess
10
12
  import threading
11
13
  from pathlib import Path
12
14
  from typing import Any, Dict
@@ -128,18 +130,57 @@ def _get_playwright_info() -> Dict[str, Any]:
128
130
  return result
129
131
 
130
132
 
131
- def _get_proxmox_info() -> Dict[str, Any]:
132
- """Detect if the current host is a Proxmox node."""
133
- info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
133
+ def _run_probe_command(cmd: list[str]) -> str | None:
134
+ try:
135
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=3)
136
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
137
+ return None
138
+ return result.stdout.strip()
139
+
140
+
141
+ def _parse_pveversion(output: str) -> str | None:
142
+ first_token = output.split(None, 1)[0] if output else ""
143
+ if not first_token:
144
+ return None
145
+ if "/" in first_token:
146
+ return first_token.split("/", 1)[1]
147
+ return first_token
148
+
149
+
150
+ def _parse_dpkg_version(output: str) -> str | None:
151
+ for line in output.splitlines():
152
+ if line.lower().startswith("version:"):
153
+ return line.split(":", 1)[1].strip()
154
+ return None
155
+
156
+
157
+ def _get_proxmox_version() -> str | None:
134
158
  release_file = Path("/etc/proxmox-release")
135
159
  if release_file.exists():
136
- info["is_proxmox_node"] = True
137
160
  try:
138
- info["version"] = release_file.read_text().strip()
161
+ return release_file.read_text().strip()
139
162
  except Exception:
140
- info["version"] = None
141
- elif Path("/etc/pve").exists():
163
+ pass
164
+ value = _run_probe_command(["pveversion"])
165
+ parsed = _parse_pveversion(value or "")
166
+ if parsed:
167
+ return parsed
168
+ for pkg in ("pve-manager", "proxmox-ve"):
169
+ pkg_output = _run_probe_command(["dpkg", "-s", pkg])
170
+ parsed = _parse_dpkg_version(pkg_output or "")
171
+ if parsed:
172
+ return parsed
173
+ return None
174
+
175
+
176
+ def _get_proxmox_info() -> Dict[str, Any]:
177
+ """Detect if the current host is a Proxmox node."""
178
+ info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
179
+ if Path("/etc/proxmox-release").exists() or Path("/etc/pve").exists():
142
180
  info["is_proxmox_node"] = True
181
+ version = _get_proxmox_version()
182
+ if version:
183
+ info["version"] = version
143
184
  info["infra"] = get_infra_snapshot()
144
185
  return info
145
186
 
@@ -53,6 +53,8 @@ from .handlers import (
53
53
  ProjectStateGitCommitHandler,
54
54
  UpdatePortacodeHandler,
55
55
  ConfigureProxmoxInfraHandler,
56
+ CreateProxmoxContainerHandler,
57
+ RevertProxmoxInfraHandler,
56
58
  )
57
59
  from .handlers.project_aware_file_handlers import (
58
60
  ProjectAwareFileWriteHandler,
@@ -474,6 +476,8 @@ class TerminalManager:
474
476
  self._command_registry.register(ProjectStateGitCommitHandler)
475
477
  # System management handlers
476
478
  self._command_registry.register(ConfigureProxmoxInfraHandler)
479
+ self._command_registry.register(CreateProxmoxContainerHandler)
480
+ self._command_registry.register(RevertProxmoxInfraHandler)
477
481
  self._command_registry.register(UpdatePortacodeHandler)
478
482
 
479
483
  # ---------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.11.dev0
3
+ Version: 1.4.11.dev5
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,8 +1,8 @@
1
1
  portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
2
2
  portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
3
3
  portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
4
- portacode/_version.py,sha256=V3stafZrckfijvIH49elVCm8cQ_x4AD5VnTvqB60X_0,719
5
- portacode/cli.py,sha256=R6BkYM-o2MCUETk-o2h-U_24E96EbVmQpIXJJY2H08Y,20387
4
+ portacode/_version.py,sha256=etzGixK9D-N8iE-N-EQixfapn6X63fGLccK__6i42Js,719
5
+ portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
6
6
  portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
7
7
  portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
8
8
  portacode/logging_categories.py,sha256=9m-BYrjyHh1vjZYBQT4JhAh6b_oYUhIWayO-noH1cSE,5063
@@ -12,20 +12,20 @@ portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJ
12
12
  portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
13
13
  portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
14
14
  portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
15
- portacode/connection/terminal.py,sha256=UpH3FywKSY2l07k76sa4B3n31ecb4Jm5QsvkTZPGCIo,44395
15
+ portacode/connection/terminal.py,sha256=NFkbbJe4Rz5XUi1WYEf9kXJqH8ETqqC9tcQhVORG3Y8,44599
16
16
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
17
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=De95QT1lT69moV64Qubhrxb7xE-j8lvSoD_-Svyhr9s,86690
18
- portacode/connection/handlers/__init__.py,sha256=4LuqzmVtD_O7EtN6TTGZc2Co6UD2dmPCJ_6qcIKEfz8,2496
17
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=1cYtf0iJBtY_Q4dadmKSdcHd67zg1f2mf-Gnm4-oI08,90511
18
+ portacode/connection/handlers/__init__.py,sha256=iiyF3smwiI0IeDYzWQTl2PPVfW6aSp-g2CSO1ZTo9Ho,2641
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=YocSgNus0TQPOEPUBuuXy-crhyCfpPZV3Mhlwo7iwQQ,10903
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=P9XjBuHkqRsJYK9Y_Ei7kTyK__cwkf2u-W5-3uVB_cw,29286
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=KfmLC5WOZR7gMOrL9mf6_XLhK5VuDhzgOmOdLi0qyLw,9494
28
+ portacode/connection/handlers/system_handlers.py,sha256=AKh7IbwptlLYrbSw5f-DHigvlaKHsg9lDP-lkAUm8cE,10755
29
29
  portacode/connection/handlers/tab_factory.py,sha256=yn93h6GASjD1VpvW1oqpax3EpoT0r7r97zFXxML1wdA,16173
30
30
  portacode/connection/handlers/terminal_handlers.py,sha256=HRwHW1GiqG1NtHVEqXHKaYkFfQEzCDDH6YIlHcb4XD8,11866
31
31
  portacode/connection/handlers/update_handler.py,sha256=f2K4LmG4sHJZ3LahzzoRtHBULTKkPUNwuyhwuAAg3RA,2054
@@ -64,7 +64,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
64
64
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
65
65
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
66
66
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
67
- portacode-1.4.11.dev0.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
67
+ portacode-1.4.11.dev5.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.dev0.dist-info/METADATA,sha256=XXzgUM_MnhswA3L7SOeRKjeG64Si7CWErIWs_atPhtY,13051
94
- portacode-1.4.11.dev0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
95
- portacode-1.4.11.dev0.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
96
- portacode-1.4.11.dev0.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
97
- portacode-1.4.11.dev0.dist-info/RECORD,,
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,,