portacode 1.4.11.dev1__py3-none-any.whl → 1.4.11.dev2__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 +2 -2
- portacode/cli.py +39 -0
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +46 -0
- portacode/connection/handlers/__init__.py +6 -1
- portacode/connection/handlers/proxmox_infra.py +408 -1
- portacode/connection/handlers/system_handlers.py +47 -14
- portacode/connection/terminal.py +2 -0
- {portacode-1.4.11.dev1.dist-info → portacode-1.4.11.dev2.dist-info}/METADATA +1 -1
- {portacode-1.4.11.dev1.dist-info → portacode-1.4.11.dev2.dist-info}/RECORD +13 -13
- {portacode-1.4.11.dev1.dist-info → portacode-1.4.11.dev2.dist-info}/WHEEL +0 -0
- {portacode-1.4.11.dev1.dist-info → portacode-1.4.11.dev2.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.11.dev1.dist-info → portacode-1.4.11.dev2.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.11.dev1.dist-info → portacode-1.4.11.dev2.dist-info}/top_level.txt +0 -0
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.
|
|
32
|
-
__version_tuple__ = version_tuple = (1, 4, 11, '
|
|
31
|
+
__version__ = version = '1.4.11.dev2'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 4, 11, 'dev2')
|
|
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
|
|
|
@@ -347,6 +347,39 @@ This action does not require any payload fields.
|
|
|
347
347
|
|
|
348
348
|
* On success, the device will emit a [`proxmox_infra_reverted`](#proxmox_infra_reverted-event) event containing the cleared snapshot.
|
|
349
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).
|
|
382
|
+
|
|
350
383
|
### `clock_sync_request`
|
|
351
384
|
|
|
352
385
|
Internal event that devices send to the gateway to request the authoritative server timestamp (used for adjusting `portacode.utils.ntp_clock`). The gateway responds immediately with [`clock_sync_response`](#clock_sync_response).
|
|
@@ -1029,6 +1062,19 @@ Emitted after a successful `revert_proxmox_infra` action. Indicates the infra co
|
|
|
1029
1062
|
* `message` (string): Summary (e.g., "Proxmox infrastructure configuration reverted").
|
|
1030
1063
|
* `infra` (object): Snapshot with `configured=false` (matching [`system_info`](#system_info-event) `proxmox.infra`).
|
|
1031
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
|
+
|
|
1032
1078
|
### <a name="clock_sync_response"></a>`clock_sync_response`
|
|
1033
1079
|
|
|
1034
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.
|
|
@@ -40,7 +40,11 @@ from .project_state_handlers import (
|
|
|
40
40
|
ProjectStateGitRevertHandler,
|
|
41
41
|
ProjectStateGitCommitHandler,
|
|
42
42
|
)
|
|
43
|
-
from .proxmox_infra import
|
|
43
|
+
from .proxmox_infra import (
|
|
44
|
+
ConfigureProxmoxInfraHandler,
|
|
45
|
+
CreateProxmoxContainerHandler,
|
|
46
|
+
RevertProxmoxInfraHandler,
|
|
47
|
+
)
|
|
44
48
|
|
|
45
49
|
__all__ = [
|
|
46
50
|
"BaseHandler",
|
|
@@ -53,6 +57,7 @@ __all__ = [
|
|
|
53
57
|
"TerminalListHandler",
|
|
54
58
|
"SystemInfoHandler",
|
|
55
59
|
"ConfigureProxmoxInfraHandler",
|
|
60
|
+
"CreateProxmoxContainerHandler",
|
|
56
61
|
# File operation handlers (optional - register as needed)
|
|
57
62
|
"FileReadHandler",
|
|
58
63
|
"FileWriteHandler",
|
|
@@ -12,7 +12,7 @@ import sys
|
|
|
12
12
|
import time
|
|
13
13
|
from datetime import datetime
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import Any, Dict, Iterable, List, Tuple
|
|
15
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
16
16
|
|
|
17
17
|
import platformdirs
|
|
18
18
|
|
|
@@ -24,6 +24,8 @@ CONFIG_DIR = Path(platformdirs.user_config_dir("portacode"))
|
|
|
24
24
|
CONFIG_PATH = CONFIG_DIR / "proxmox_infra.json"
|
|
25
25
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
26
26
|
NET_SETUP_SCRIPT = REPO_ROOT / "proxmox_management" / "net_setup.py"
|
|
27
|
+
CONTAINERS_DIR = CONFIG_DIR / "containers"
|
|
28
|
+
MANAGED_MARKER = "portacode-managed:true"
|
|
27
29
|
|
|
28
30
|
DEFAULT_HOST = "localhost"
|
|
29
31
|
DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
|
|
@@ -233,6 +235,327 @@ def _revert_bridge() -> None:
|
|
|
233
235
|
logger.warning("Proxmox bridge revert failed: %s", exc)
|
|
234
236
|
|
|
235
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
|
+
|
|
236
559
|
def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
237
560
|
network = config.get("network", {})
|
|
238
561
|
base_network = {
|
|
@@ -326,6 +649,90 @@ def revert_infrastructure() -> Dict[str, Any]:
|
|
|
326
649
|
return snapshot
|
|
327
650
|
|
|
328
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
|
+
if os.geteuid() != 0:
|
|
695
|
+
raise PermissionError("Container creation requires root privileges.")
|
|
696
|
+
config = _load_config()
|
|
697
|
+
if not config or not config.get("token_value"):
|
|
698
|
+
raise ValueError("Proxmox infrastructure is not configured.")
|
|
699
|
+
if not config.get("network", {}).get("applied"):
|
|
700
|
+
raise RuntimeError("Proxmox bridge setup must be applied before creating containers.")
|
|
701
|
+
|
|
702
|
+
proxmox = _connect_proxmox(config)
|
|
703
|
+
node = config.get("node") or DEFAULT_NODE_NAME
|
|
704
|
+
payload = _build_container_payload(message, config)
|
|
705
|
+
payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
|
|
706
|
+
payload["memory"] = int(payload["ram_mib"])
|
|
707
|
+
payload["node"] = node
|
|
708
|
+
|
|
709
|
+
vmid, _ = _instantiate_container(proxmox, node, payload)
|
|
710
|
+
payload["vmid"] = vmid
|
|
711
|
+
payload["created_at"] = datetime.utcnow().isoformat() + "Z"
|
|
712
|
+
_write_container_record(vmid, payload)
|
|
713
|
+
|
|
714
|
+
_start_container(proxmox, node, vmid)
|
|
715
|
+
public_key, steps = _bootstrap_portacode(vmid, payload["username"], payload["password"], payload["ssh_public_key"])
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
"event": "proxmox_container_created",
|
|
719
|
+
"success": True,
|
|
720
|
+
"message": f"Container {vmid} is ready and Portacode key captured.",
|
|
721
|
+
"ctid": str(vmid),
|
|
722
|
+
"public_key": public_key,
|
|
723
|
+
"container": {
|
|
724
|
+
"vmid": vmid,
|
|
725
|
+
"hostname": payload["hostname"],
|
|
726
|
+
"template": payload["template"],
|
|
727
|
+
"storage": payload["storage"],
|
|
728
|
+
"disk_gib": payload["disk_gib"],
|
|
729
|
+
"ram_mib": payload["ram_mib"],
|
|
730
|
+
"cpus": payload["cpus"],
|
|
731
|
+
},
|
|
732
|
+
"setup_steps": steps,
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
|
|
329
736
|
class ConfigureProxmoxInfraHandler(SyncHandler):
|
|
330
737
|
@property
|
|
331
738
|
def command_name(self) -> str:
|
|
@@ -7,6 +7,7 @@ import logging
|
|
|
7
7
|
import os
|
|
8
8
|
import platform
|
|
9
9
|
import shutil
|
|
10
|
+
import subprocess
|
|
10
11
|
import threading
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, Dict
|
|
@@ -128,25 +129,57 @@ def _get_playwright_info() -> Dict[str, Any]:
|
|
|
128
129
|
return result
|
|
129
130
|
|
|
130
131
|
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
def _run_probe_command(cmd: list[str]) -> str | None:
|
|
133
|
+
try:
|
|
134
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=3)
|
|
135
|
+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
136
|
+
return None
|
|
137
|
+
return result.stdout.strip()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _parse_pveversion(output: str) -> str | None:
|
|
141
|
+
first_token = output.split(None, 1)[0] if output else ""
|
|
142
|
+
if not first_token:
|
|
143
|
+
return None
|
|
144
|
+
if "/" in first_token:
|
|
145
|
+
return first_token.split("/", 1)[1]
|
|
146
|
+
return first_token
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parse_dpkg_version(output: str) -> str | None:
|
|
150
|
+
for line in output.splitlines():
|
|
151
|
+
if line.lower().startswith("version:"):
|
|
152
|
+
return line.split(":", 1)[1].strip()
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _get_proxmox_version() -> str | None:
|
|
134
157
|
release_file = Path("/etc/proxmox-release")
|
|
135
158
|
if release_file.exists():
|
|
136
|
-
info["is_proxmox_node"] = True
|
|
137
159
|
try:
|
|
138
|
-
|
|
160
|
+
return release_file.read_text().strip()
|
|
139
161
|
except Exception:
|
|
140
|
-
|
|
141
|
-
|
|
162
|
+
pass
|
|
163
|
+
value = _run_probe_command(["pveversion"])
|
|
164
|
+
parsed = _parse_pveversion(value or "")
|
|
165
|
+
if parsed:
|
|
166
|
+
return parsed
|
|
167
|
+
for pkg in ("pve-manager", "proxmox-ve"):
|
|
168
|
+
pkg_output = _run_probe_command(["dpkg", "-s", pkg])
|
|
169
|
+
parsed = _parse_dpkg_version(pkg_output or "")
|
|
170
|
+
if parsed:
|
|
171
|
+
return parsed
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _get_proxmox_info() -> Dict[str, Any]:
|
|
176
|
+
"""Detect if the current host is a Proxmox node."""
|
|
177
|
+
info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
|
|
178
|
+
if Path("/etc/proxmox-release").exists() or Path("/etc/pve").exists():
|
|
142
179
|
info["is_proxmox_node"] = True
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
try:
|
|
147
|
-
info["version"] = version_hint.read_text().strip()
|
|
148
|
-
except Exception:
|
|
149
|
-
info["version"] = None
|
|
180
|
+
version = _get_proxmox_version()
|
|
181
|
+
if version:
|
|
182
|
+
info["version"] = version
|
|
150
183
|
info["infra"] = get_infra_snapshot()
|
|
151
184
|
return info
|
|
152
185
|
|
portacode/connection/terminal.py
CHANGED
|
@@ -53,6 +53,7 @@ from .handlers import (
|
|
|
53
53
|
ProjectStateGitCommitHandler,
|
|
54
54
|
UpdatePortacodeHandler,
|
|
55
55
|
ConfigureProxmoxInfraHandler,
|
|
56
|
+
CreateProxmoxContainerHandler,
|
|
56
57
|
RevertProxmoxInfraHandler,
|
|
57
58
|
)
|
|
58
59
|
from .handlers.project_aware_file_handlers import (
|
|
@@ -475,6 +476,7 @@ class TerminalManager:
|
|
|
475
476
|
self._command_registry.register(ProjectStateGitCommitHandler)
|
|
476
477
|
# System management handlers
|
|
477
478
|
self._command_registry.register(ConfigureProxmoxInfraHandler)
|
|
479
|
+
self._command_registry.register(CreateProxmoxContainerHandler)
|
|
478
480
|
self._command_registry.register(RevertProxmoxInfraHandler)
|
|
479
481
|
self._command_registry.register(UpdatePortacodeHandler)
|
|
480
482
|
|
|
@@ -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=
|
|
5
|
-
portacode/cli.py,sha256=
|
|
4
|
+
portacode/_version.py,sha256=DyyohhLO48-FVlZsoOf271qw5sfxzwugB2wwyNnnQyQ,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=
|
|
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=
|
|
18
|
-
portacode/connection/handlers/__init__.py,sha256=
|
|
17
|
+
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=1cYtf0iJBtY_Q4dadmKSdcHd67zg1f2mf-Gnm4-oI08,90511
|
|
18
|
+
portacode/connection/handlers/__init__.py,sha256=ZyhqJDgU5t5p83sCIvAGpAPZ1ShHU2a40q-KYnq9Uf8,2590
|
|
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=
|
|
25
|
+
portacode/connection/handlers/proxmox_infra.py,sha256=eNK905IoelaFDkeHu57RUMtQzSU26CNPtJXxqPwZ6NI,28452
|
|
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=
|
|
28
|
+
portacode/connection/handlers/system_handlers.py,sha256=KY5twX1_90hCCwk8RmbkORdjqGWmmAD2WanV4X0Zc1c,10720
|
|
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.
|
|
67
|
+
portacode-1.4.11.dev2.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.
|
|
94
|
-
portacode-1.4.11.
|
|
95
|
-
portacode-1.4.11.
|
|
96
|
-
portacode-1.4.11.
|
|
97
|
-
portacode-1.4.11.
|
|
93
|
+
portacode-1.4.11.dev2.dist-info/METADATA,sha256=L4EQ_bW0QXbeefqzzoRNOE7f2XEBfEiRS7wWJG82mTE,13051
|
|
94
|
+
portacode-1.4.11.dev2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
95
|
+
portacode-1.4.11.dev2.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
|
|
96
|
+
portacode-1.4.11.dev2.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
|
|
97
|
+
portacode-1.4.11.dev2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|