portacode 1.4.15.dev4__py3-none-any.whl → 1.4.15.dev6__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/connection/handlers/WEBSOCKET_PROTOCOL.md +14 -102
- portacode/connection/handlers/__init__.py +0 -6
- portacode/connection/handlers/proxmox_infra.py +49 -436
- portacode/connection/terminal.py +0 -6
- {portacode-1.4.15.dev4.dist-info → portacode-1.4.15.dev6.dist-info}/METADATA +1 -1
- {portacode-1.4.15.dev4.dist-info → portacode-1.4.15.dev6.dist-info}/RECORD +11 -11
- {portacode-1.4.15.dev4.dist-info → portacode-1.4.15.dev6.dist-info}/WHEEL +0 -0
- {portacode-1.4.15.dev4.dist-info → portacode-1.4.15.dev6.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.15.dev4.dist-info → portacode-1.4.15.dev6.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.15.dev4.dist-info → portacode-1.4.15.dev6.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.15.
|
|
32
|
-
__version_tuple__ = version_tuple = (1, 4, 15, '
|
|
31
|
+
__version__ = version = '1.4.15.dev6'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 4, 15, 'dev6')
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -27,6 +27,10 @@ The Portacode server acts as a **routing middleman** between client sessions and
|
|
|
27
27
|
|
|
28
28
|
- **`source_client_session`** (Server → Device): Server **adds this** when forwarding client commands to devices (so device knows which client sent the command and can target responses back). Clients never include this field.
|
|
29
29
|
|
|
30
|
+
### Proxying infrastructure updates
|
|
31
|
+
|
|
32
|
+
Portacode infrastructure devices (like the proxmox host) can send events on behalf of the LXC Devices they manage. Such messages include the optional `on_behalf_of_device` field and the server silently replaces `device_id` with that child device before routing. The gateway enforces that the sender is the child’s `proxmox_parent` (via `Device.proxmox_parent`) so only the infrastructure owner can impersonate a child device. Messages that fail this check are dropped.
|
|
33
|
+
|
|
30
34
|
This document describes the complete protocol for communicating with devices through the server, guiding app developers on how to get their client sessions to communicate with devices.
|
|
31
35
|
|
|
32
36
|
## Table of Contents
|
|
@@ -326,12 +330,9 @@ Configures a Proxmox node for Portacode infrastructure usage (API token validati
|
|
|
326
330
|
|
|
327
331
|
**Payload Fields:**
|
|
328
332
|
|
|
329
|
-
* `token_identifier` (string,
|
|
330
|
-
* `token_value` (string,
|
|
331
|
-
* `verify_ssl` (boolean, optional): When true, the handler verifies SSL certificates; defaults to `false`.
|
|
332
|
-
* `cloudflare_api_token` (string, optional): Cloudflare API token the host can reuse later to provision tunnels.
|
|
333
|
-
|
|
334
|
-
The setup command also installs `cloudflared` on the node so Cloudflare tunnels can be created afterward.
|
|
333
|
+
* `token_identifier` (string, required): API token identifier in the form `user@realm!tokenid`.
|
|
334
|
+
* `token_value` (string, required): Secret value associated with the token.
|
|
335
|
+
* `verify_ssl` (boolean, optional): When true, the handler verifies SSL certificates; defaults to `false`.
|
|
335
336
|
|
|
336
337
|
**Responses:**
|
|
337
338
|
|
|
@@ -364,7 +365,7 @@ Creates a Portacode-managed LXC container, starts it, and bootstraps the Portaco
|
|
|
364
365
|
* `username` (string, optional): OS user to provision (defaults to `svcuser`).
|
|
365
366
|
* `password` (string, optional): Password for the user (used only during provisioning).
|
|
366
367
|
* `ssh_key` (string, optional): SSH public key to add to the user.
|
|
367
|
-
* `device_id` (string,
|
|
368
|
+
* `device_id` (string, required): ID of the dashboard Device record that represents the container. The handler persists this value in the host metadata file so related events can always be correlated back to that Device.
|
|
368
369
|
* `device_public_key` (string, optional): PEM-encoded Portacode public key. When supplied together with `device_private_key` the handler injects the keypair, records the device metadata, and runs `portacode service install` automatically.
|
|
369
370
|
* `device_private_key` (string, optional): PEM-encoded private key that pairs with `device_public_key`. Both key fields must be present for the automatic service-install mode.
|
|
370
371
|
|
|
@@ -412,51 +413,6 @@ Deletes a managed container from Proxmox (stopping it first if necessary) and re
|
|
|
412
413
|
* Emits a [`proxmox_container_action`](#proxmox_container_action-event) event with `action="remove"` and the refreshed infra snapshot after deletion.
|
|
413
414
|
* Emits an [`error`](#error) event on failure.
|
|
414
415
|
|
|
415
|
-
### `create_cloudflare_tunnel`
|
|
416
|
-
|
|
417
|
-
Creates or updates a Cloudflare tunnel in front of a managed container. The container must be running and a Cloudflare API token must already be configured via `setup_proxmox_infra`.
|
|
418
|
-
|
|
419
|
-
**Payload Fields:**
|
|
420
|
-
|
|
421
|
-
* `ctid` (string, required): Identifier of the container.
|
|
422
|
-
* `container_port` (integer, required): Port inside the container to expose through Cloudflare.
|
|
423
|
-
* `cloudflare_url` (string, optional): Hostname (e.g., `app.example.com`) that the tunnel should serve. Leave blank to let Cloudflare create a quick tunnel (`*.cfargotunnel.com`) automatically.
|
|
424
|
-
* `protocol` (string, optional): One of `http`, `https`, or `tcp` (defaults to `http`).
|
|
425
|
-
|
|
426
|
-
**Responses:**
|
|
427
|
-
|
|
428
|
-
* Emits a [`cloudflare_tunnel_created`](#cloudflare_tunnel_created-event) event with the tunnel metadata, the refreshed infra snapshot, and the optional `device_id` so dashboards can refresh the CT list.
|
|
429
|
-
* Emits an [`error`](#error) event on failure.
|
|
430
|
-
|
|
431
|
-
### `update_cloudflare_tunnel`
|
|
432
|
-
|
|
433
|
-
Refreshes the tunnel configuration for an existing tunnel (different port, URL, or protocol).
|
|
434
|
-
|
|
435
|
-
**Payload Fields:**
|
|
436
|
-
|
|
437
|
-
* `ctid` (string, required): Identifier of the container.
|
|
438
|
-
* `container_port` (integer, optional): New container port.
|
|
439
|
-
* `cloudflare_url` (string, optional): New hostname (leave blank to keep the current hostname or let Cloudflare assign a quick tunnel).
|
|
440
|
-
* `protocol` (string, optional): New protocol (`http`, `https`, or `tcp`).
|
|
441
|
-
|
|
442
|
-
**Responses:**
|
|
443
|
-
|
|
444
|
-
* Emits a [`cloudflare_tunnel_updated`](#cloudflare_tunnel_updated-event) event with the refreshed tunnel metadata, the refreshed infra snapshot, and the optional `device_id`.
|
|
445
|
-
* Emits an [`error`](#error) event on failure.
|
|
446
|
-
|
|
447
|
-
### `remove_cloudflare_tunnel`
|
|
448
|
-
|
|
449
|
-
Stops and removes any tunnel metadata associated with a container.
|
|
450
|
-
|
|
451
|
-
**Payload Fields:**
|
|
452
|
-
|
|
453
|
-
* `ctid` (string, required): Identifier of the container.
|
|
454
|
-
|
|
455
|
-
**Responses:**
|
|
456
|
-
|
|
457
|
-
* Emits a [`cloudflare_tunnel_removed`](#cloudflare_tunnel_removed-event) event with the refreshed infra snapshot and optional `device_id`.
|
|
458
|
-
* Emits an [`error`](#error) event on failure.
|
|
459
|
-
|
|
460
416
|
### `proxmox_container_created`
|
|
461
417
|
|
|
462
418
|
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.
|
|
@@ -469,7 +425,8 @@ Emitted after a successful `create_proxmox_container` action. Contains the new c
|
|
|
469
425
|
* `public_key` (string): Portacode public auth key created inside the new container.
|
|
470
426
|
* `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
|
|
471
427
|
* `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
|
|
472
|
-
* `device_id` (string
|
|
428
|
+
* `device_id` (string): Mirrors the dashboard Device ID supplied with `create_proxmox_container`. The handler records this value in the container metadata file so subsequent events can reference the same Device.
|
|
429
|
+
* `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device; only proxmox parents may include this field.
|
|
473
430
|
* `service_installed` (boolean): True when the handler already ran `portacode service install` (with a provided keypair); otherwise it remains False and the dashboard can call `start_portacode_service`.
|
|
474
431
|
|
|
475
432
|
### `proxmox_container_progress`
|
|
@@ -487,6 +444,7 @@ Sent intermittently while `create_proxmox_container` is executing so callers can
|
|
|
487
444
|
* `message` (string): Short description of what is happening or why a failure occurred.
|
|
488
445
|
* `details` (object, optional): Contains `attempt` (if retries were needed) and `error_summary` when a step fails.
|
|
489
446
|
* `request_id` (string, optional): Mirrors the request ID from the incoming `create_proxmox_container` payload when available.
|
|
447
|
+
* `on_behalf_of_device` (string, optional): When present the proxmox device is reporting progress for the referenced dashboard device; the gateway verifies the proxmox node is the child’s `proxmox_parent` before routing the event.
|
|
490
448
|
|
|
491
449
|
### `start_portacode_service`
|
|
492
450
|
|
|
@@ -1174,8 +1132,6 @@ Provides system information in response to a `system_info` action. Handled by [`
|
|
|
1174
1132
|
* `message` (string|null): Informational text about the network setup attempt.
|
|
1175
1133
|
* `bridge` (string): The bridge interface configured (typically `vmbr1`).
|
|
1176
1134
|
* `health` (string|null): `"healthy"` when the connectivity verification succeeded.
|
|
1177
|
-
* `cloudflare` (object): Optional Cloudflare metadata collected for future tunnels:
|
|
1178
|
-
* `configured` (boolean): True when a Cloudflare API token is stored.
|
|
1179
1135
|
* `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
|
|
1180
1136
|
* `managed_containers` (object): Cached summary of the Portacode-managed containers:
|
|
1181
1137
|
* `updated_at` (string): ISO timestamp when this snapshot was last refreshed.
|
|
@@ -1193,11 +1149,6 @@ Provides system information in response to a `system_info` action. Handled by [`
|
|
|
1193
1149
|
* `cpu_share` (number): vCPU-equivalent share requested at creation.
|
|
1194
1150
|
* `status` (string): Lowercase lifecycle status (e.g., `running`, `stopped`, `deleted`).
|
|
1195
1151
|
* `created_at` (string|null): ISO timestamp recorded when the CT was provisioned.
|
|
1196
|
-
* `tunnel` (object|null): Tunnel metadata that includes:
|
|
1197
|
-
* `url` (string): Public hostname assigned for this tunnel.
|
|
1198
|
-
* `container_port` (integer): Container port exposed via the tunnel.
|
|
1199
|
-
* `protocol` (string): Protocol advertised when the tunnel was configured.
|
|
1200
|
-
* `status` (string): `running`, `stopped`, or `unknown`.
|
|
1201
1152
|
* `portacode_version` (string): Installed CLI version returned by `portacode.__version__`.
|
|
1202
1153
|
|
|
1203
1154
|
### `proxmox_infra_configured`
|
|
@@ -1232,6 +1183,8 @@ Emitted after a successful `create_proxmox_container` action to report the newly
|
|
|
1232
1183
|
* `public_key` (string): Portacode public auth key discovered inside the container.
|
|
1233
1184
|
* `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
|
|
1234
1185
|
* `setup_steps` (array[object]): Detailed bootstrap step reports including stdout/stderr, elapsed time, and pass/fail status.
|
|
1186
|
+
* `device_id` (string): Mirrors the device ID supplied with `create_proxmox_container` and persisted inside the host metadata file for this CT.
|
|
1187
|
+
* `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device.
|
|
1235
1188
|
|
|
1236
1189
|
### `proxmox_container_progress`
|
|
1237
1190
|
|
|
@@ -1248,6 +1201,7 @@ Sent continuously while `create_proxmox_container` runs so dashboards can show a
|
|
|
1248
1201
|
* `message` (string): Short human-readable description of the action or failure.
|
|
1249
1202
|
* `details` (object, optional): Contains `attempt` (when retries are used) and `error_summary` on failure.
|
|
1250
1203
|
* `request_id` (string, optional): Mirrors the `create_proxmox_container` request when provided.
|
|
1204
|
+
* `on_behalf_of_device` (string, optional): Mirrors the child device ID when a proxmox host reports progress for that child; only proxmox parents can supply this field.
|
|
1251
1205
|
|
|
1252
1206
|
### `proxmox_container_action`
|
|
1253
1207
|
|
|
@@ -1263,48 +1217,6 @@ Emitted after `start_proxmox_container`, `stop_proxmox_container`, or `remove_pr
|
|
|
1263
1217
|
* `details` (object, optional): Exit status information (e.g., `exitstatus`, `stop_exitstatus`, `delete_exitstatus`).
|
|
1264
1218
|
* `infra` (object): Same snapshot described under [`system_info`](#system_info-event) `proxmox.infra`, including the updated `managed_containers` summary.
|
|
1265
1219
|
|
|
1266
|
-
### `cloudflare_tunnel_created`
|
|
1267
|
-
|
|
1268
|
-
Submitted after a successful `create_cloudflare_tunnel` action.
|
|
1269
|
-
|
|
1270
|
-
**Event Fields:**
|
|
1271
|
-
|
|
1272
|
-
* `event` (string): `cloudflare_tunnel_created`.
|
|
1273
|
-
* `ctid` (string): Container ID associated with the tunnel.
|
|
1274
|
-
* `success` (boolean): True when the tunnel is running.
|
|
1275
|
-
* `device_id` (string, optional): Mirrors the `device_id` supplied with the command, letting dashboards refresh that device immediately.
|
|
1276
|
-
* `message` (string): Summary text; when a hostname was assigned (including Quick Tunnel), the host is appended (e.g., `Created ... -> example.com`).
|
|
1277
|
-
* `tunnel` (object): Tunnel metadata matching the manager view. When using Cloudflare Quick Tunnel (no hostname supplied), `tunnel.url` holds the autogenerated `*.cfargotunnel.com` hostname.
|
|
1278
|
-
* `infra` (object): Snapshot produced by `get_infra_snapshot`, including the refreshed `managed_containers` list.
|
|
1279
|
-
|
|
1280
|
-
### `cloudflare_tunnel_updated`
|
|
1281
|
-
|
|
1282
|
-
Sent when `update_cloudflare_tunnel` completes.
|
|
1283
|
-
|
|
1284
|
-
**Event Fields:**
|
|
1285
|
-
|
|
1286
|
-
* `event` (string): `cloudflare_tunnel_updated`.
|
|
1287
|
-
* `ctid` (string): Container ID associated with the tunnel.
|
|
1288
|
-
* `success` (boolean): True on success.
|
|
1289
|
-
* `device_id` (string, optional): Mirrors the command's `device_id`.
|
|
1290
|
-
* `message` (string): Summary text; includes the new hostname when one was assigned or changed.
|
|
1291
|
-
* `tunnel` (object): Updated tunnel metadata.
|
|
1292
|
-
* `infra` (object): Snapshot produced by `get_infra_snapshot` so dashboards can refresh the managed container list.
|
|
1293
|
-
|
|
1294
|
-
### `cloudflare_tunnel_removed`
|
|
1295
|
-
|
|
1296
|
-
Sent after a tunnel has been removed from a container record.
|
|
1297
|
-
|
|
1298
|
-
**Event Fields:**
|
|
1299
|
-
|
|
1300
|
-
* `event` (string): `cloudflare_tunnel_removed`.
|
|
1301
|
-
* `ctid` (string): Container ID that no longer has a tunnel.
|
|
1302
|
-
* `success` (boolean): True on success.
|
|
1303
|
-
* `device_id` (string, optional): Mirrors the command's `device_id`.
|
|
1304
|
-
* `message` (string): Summary text.
|
|
1305
|
-
* `tunnel` (null): Always `null` to explicitly clear the stored tunnel metadata.
|
|
1306
|
-
* `infra` (object): The refreshed snapshot so dashboards see the updated container list.
|
|
1307
|
-
|
|
1308
1220
|
### <a name="clock_sync_response"></a>`clock_sync_response`
|
|
1309
1221
|
|
|
1310
1222
|
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.
|
|
@@ -43,15 +43,12 @@ from .project_state_handlers import (
|
|
|
43
43
|
)
|
|
44
44
|
from .proxmox_infra import (
|
|
45
45
|
ConfigureProxmoxInfraHandler,
|
|
46
|
-
CreateCloudflareTunnelHandler,
|
|
47
46
|
CreateProxmoxContainerHandler,
|
|
48
47
|
RevertProxmoxInfraHandler,
|
|
49
48
|
StartPortacodeServiceHandler,
|
|
50
49
|
StartProxmoxContainerHandler,
|
|
51
50
|
StopProxmoxContainerHandler,
|
|
52
|
-
RemoveCloudflareTunnelHandler,
|
|
53
51
|
RemoveProxmoxContainerHandler,
|
|
54
|
-
UpdateCloudflareTunnelHandler,
|
|
55
52
|
)
|
|
56
53
|
|
|
57
54
|
__all__ = [
|
|
@@ -65,7 +62,6 @@ __all__ = [
|
|
|
65
62
|
"TerminalListHandler",
|
|
66
63
|
"SystemInfoHandler",
|
|
67
64
|
"ConfigureProxmoxInfraHandler",
|
|
68
|
-
"CreateCloudflareTunnelHandler",
|
|
69
65
|
"CreateProxmoxContainerHandler",
|
|
70
66
|
# File operation handlers (optional - register as needed)
|
|
71
67
|
"FileReadHandler",
|
|
@@ -95,9 +91,7 @@ __all__ = [
|
|
|
95
91
|
"StartPortacodeServiceHandler",
|
|
96
92
|
"StartProxmoxContainerHandler",
|
|
97
93
|
"StopProxmoxContainerHandler",
|
|
98
|
-
"RemoveCloudflareTunnelHandler",
|
|
99
94
|
"RemoveProxmoxContainerHandler",
|
|
100
|
-
"UpdateCloudflareTunnelHandler",
|
|
101
95
|
"UpdatePortacodeHandler",
|
|
102
96
|
"RevertProxmoxInfraHandler",
|
|
103
97
|
]
|
|
@@ -8,8 +8,6 @@ import logging
|
|
|
8
8
|
import os
|
|
9
9
|
import secrets
|
|
10
10
|
import shlex
|
|
11
|
-
import re
|
|
12
|
-
import select
|
|
13
11
|
import shutil
|
|
14
12
|
import stat
|
|
15
13
|
import subprocess
|
|
@@ -17,7 +15,6 @@ import sys
|
|
|
17
15
|
import tempfile
|
|
18
16
|
import time
|
|
19
17
|
import threading
|
|
20
|
-
import urllib.request
|
|
21
18
|
from datetime import datetime
|
|
22
19
|
from pathlib import Path
|
|
23
20
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
@@ -43,15 +40,12 @@ BRIDGE_IP = SUBNET_CIDR.split("/", 1)[0]
|
|
|
43
40
|
DHCP_START = "10.10.0.100"
|
|
44
41
|
DHCP_END = "10.10.0.200"
|
|
45
42
|
DNS_SERVER = "1.1.1.1"
|
|
46
|
-
CLOUDFLARE_DEB_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb"
|
|
47
43
|
IFACES_PATH = Path("/etc/network/interfaces")
|
|
48
44
|
SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
|
|
49
45
|
UNIT_DIR = Path("/etc/systemd/system")
|
|
50
46
|
_MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
|
|
51
47
|
_MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
|
|
52
48
|
_MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
|
|
53
|
-
_CLOUDFLARE_TUNNEL_PROCESSES: Dict[str, subprocess.Popen] = {}
|
|
54
|
-
_CLOUDFLARE_TUNNELS_LOCK = threading.Lock()
|
|
55
49
|
|
|
56
50
|
ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
|
|
57
51
|
|
|
@@ -68,6 +62,7 @@ def _emit_progress_event(
|
|
|
68
62
|
phase: str,
|
|
69
63
|
request_id: Optional[str],
|
|
70
64
|
details: Optional[Dict[str, Any]] = None,
|
|
65
|
+
on_behalf_of_device: Optional[str] = None,
|
|
71
66
|
) -> None:
|
|
72
67
|
loop = handler.context.get("event_loop")
|
|
73
68
|
if not loop or loop.is_closed():
|
|
@@ -92,6 +87,8 @@ def _emit_progress_event(
|
|
|
92
87
|
payload["request_id"] = request_id
|
|
93
88
|
if details:
|
|
94
89
|
payload["details"] = details
|
|
90
|
+
if on_behalf_of_device:
|
|
91
|
+
payload["on_behalf_of_device"] = str(on_behalf_of_device)
|
|
95
92
|
|
|
96
93
|
future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
|
|
97
94
|
future.add_done_callback(
|
|
@@ -285,25 +282,6 @@ def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
|
|
|
285
282
|
return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
|
|
286
283
|
|
|
287
284
|
|
|
288
|
-
def _ensure_cloudflared_installed() -> None:
|
|
289
|
-
if shutil.which("cloudflared"):
|
|
290
|
-
return
|
|
291
|
-
apt = shutil.which("apt-get")
|
|
292
|
-
if not apt:
|
|
293
|
-
raise RuntimeError("cloudflared is missing and apt-get is unavailable to install it")
|
|
294
|
-
download_dir = Path(tempfile.mkdtemp())
|
|
295
|
-
deb_path = download_dir / "cloudflared.deb"
|
|
296
|
-
try:
|
|
297
|
-
urllib.request.urlretrieve(CLOUDFLARE_DEB_URL, deb_path)
|
|
298
|
-
try:
|
|
299
|
-
_call_subprocess(["dpkg", "-i", str(deb_path)], check=True)
|
|
300
|
-
except subprocess.CalledProcessError:
|
|
301
|
-
_call_subprocess([apt, "install", "-f", "-y"], check=True)
|
|
302
|
-
_call_subprocess(["dpkg", "-i", str(deb_path)], check=True)
|
|
303
|
-
finally:
|
|
304
|
-
shutil.rmtree(download_dir, ignore_errors=True)
|
|
305
|
-
|
|
306
|
-
|
|
307
285
|
def _verify_connectivity(timeout: float = 5.0) -> bool:
|
|
308
286
|
try:
|
|
309
287
|
_call_subprocess(["/bin/ping", "-c", "2", "1.1.1.1"], check=True, timeout=timeout)
|
|
@@ -380,7 +358,6 @@ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str
|
|
|
380
358
|
"cpu_share": cpu_share,
|
|
381
359
|
"created_at": record.get("created_at"),
|
|
382
360
|
"status": status,
|
|
383
|
-
"tunnel": record.get("tunnel"),
|
|
384
361
|
}
|
|
385
362
|
)
|
|
386
363
|
|
|
@@ -618,89 +595,6 @@ def _remove_container_record(vmid: int) -> None:
|
|
|
618
595
|
_invalidate_managed_containers_cache()
|
|
619
596
|
|
|
620
597
|
|
|
621
|
-
def _update_container_tunnel(vmid: int, tunnel: Optional[Dict[str, Any]]) -> None:
|
|
622
|
-
record = _read_container_record(vmid)
|
|
623
|
-
if tunnel:
|
|
624
|
-
record["tunnel"] = tunnel
|
|
625
|
-
else:
|
|
626
|
-
record.pop("tunnel", None)
|
|
627
|
-
_write_container_record(vmid, record)
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
def _ensure_cloudflare_token(config: Dict[str, Any]) -> str:
|
|
631
|
-
cloudflare = config.get("cloudflare") or {}
|
|
632
|
-
token = cloudflare.get("api_token")
|
|
633
|
-
if not token:
|
|
634
|
-
raise RuntimeError("Cloudflare API token is not configured.")
|
|
635
|
-
return token
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
def _launch_container_tunnel(proxmox: Any, node: str, vmid: int, tunnel: Dict[str, Any]) -> Dict[str, Any]:
|
|
639
|
-
port = int(tunnel.get("container_port") or 0)
|
|
640
|
-
if not port:
|
|
641
|
-
raise ValueError("container_port is required to create a tunnel.")
|
|
642
|
-
requested_hostname = tunnel.get("url") or None
|
|
643
|
-
protocol = (tunnel.get("protocol") or "http").lower()
|
|
644
|
-
ip_address = _resolve_container_ip(proxmox, node, vmid)
|
|
645
|
-
target_url = f"{protocol}://{ip_address}:{port}"
|
|
646
|
-
name = tunnel.get("name") or _build_tunnel_name(vmid, port)
|
|
647
|
-
_stop_cloudflare_process(name)
|
|
648
|
-
proc, assigned_url = _start_cloudflare_process(name, target_url, hostname=requested_hostname)
|
|
649
|
-
if not assigned_url:
|
|
650
|
-
raise RuntimeError("Failed to determine Cloudflare hostname for the tunnel.")
|
|
651
|
-
updated = {
|
|
652
|
-
"name": name,
|
|
653
|
-
"container_port": port,
|
|
654
|
-
"url": assigned_url,
|
|
655
|
-
"protocol": protocol,
|
|
656
|
-
"status": "running",
|
|
657
|
-
"pid": proc.pid,
|
|
658
|
-
"target_ip": ip_address,
|
|
659
|
-
"target_url": target_url,
|
|
660
|
-
"last_updated": datetime.utcnow().isoformat() + "Z",
|
|
661
|
-
}
|
|
662
|
-
_update_container_tunnel(vmid, updated)
|
|
663
|
-
return updated
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
def _stop_container_tunnel(vmid: int) -> None:
|
|
667
|
-
try:
|
|
668
|
-
record = _read_container_record(vmid)
|
|
669
|
-
except FileNotFoundError:
|
|
670
|
-
return
|
|
671
|
-
tunnel = record.get("tunnel")
|
|
672
|
-
if not tunnel:
|
|
673
|
-
return
|
|
674
|
-
name = tunnel.get("name") or _build_tunnel_name(vmid, int(tunnel.get("container_port") or 0))
|
|
675
|
-
stopped = _stop_cloudflare_process(name)
|
|
676
|
-
if not stopped and tunnel.get("status") == "stopped":
|
|
677
|
-
return
|
|
678
|
-
tunnel_update = {
|
|
679
|
-
**tunnel,
|
|
680
|
-
"status": "stopped",
|
|
681
|
-
"pid": None,
|
|
682
|
-
"last_updated": datetime.utcnow().isoformat() + "Z",
|
|
683
|
-
}
|
|
684
|
-
_update_container_tunnel(vmid, tunnel_update)
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
def _remove_container_tunnel_state(vmid: int) -> None:
|
|
688
|
-
_stop_container_tunnel(vmid)
|
|
689
|
-
_update_container_tunnel(vmid, None)
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
def _ensure_container_tunnel_running(proxmox: Any, node: str, vmid: int) -> None:
|
|
693
|
-
try:
|
|
694
|
-
record = _read_container_record(vmid)
|
|
695
|
-
except FileNotFoundError:
|
|
696
|
-
return
|
|
697
|
-
tunnel = record.get("tunnel")
|
|
698
|
-
if not tunnel:
|
|
699
|
-
return
|
|
700
|
-
_ensure_cloudflare_token(_load_config())
|
|
701
|
-
_launch_container_tunnel(proxmox, node, vmid, tunnel)
|
|
702
|
-
|
|
703
|
-
|
|
704
598
|
def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
|
|
705
599
|
templates = config.get("templates") or []
|
|
706
600
|
default_template = templates[0] if templates else ""
|
|
@@ -816,116 +710,6 @@ def _run_pct_push(vmid: int, src: str, dest: str) -> subprocess.CompletedProcess
|
|
|
816
710
|
return _call_subprocess(["pct", "push", str(vmid), src, dest])
|
|
817
711
|
|
|
818
712
|
|
|
819
|
-
def _build_tunnel_name(vmid: int, port: int) -> str:
|
|
820
|
-
return f"portacode-ct{vmid}-{port}"
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
def _get_cloudflared_binary() -> str:
|
|
824
|
-
binary = shutil.which("cloudflared")
|
|
825
|
-
if not binary:
|
|
826
|
-
raise RuntimeError(
|
|
827
|
-
"cloudflared is required for Cloudflare tunnels but was not found on PATH. "
|
|
828
|
-
"Install cloudflared and run 'cloudflared tunnel login' before creating tunnels."
|
|
829
|
-
)
|
|
830
|
-
return binary
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
def _drain_stream(stream: Optional[Any]) -> None:
|
|
834
|
-
if stream is None:
|
|
835
|
-
return
|
|
836
|
-
try:
|
|
837
|
-
for _ in iter(stream.readline, ""):
|
|
838
|
-
continue
|
|
839
|
-
except Exception:
|
|
840
|
-
pass
|
|
841
|
-
finally:
|
|
842
|
-
try:
|
|
843
|
-
stream.close()
|
|
844
|
-
except Exception:
|
|
845
|
-
pass
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
def _await_quick_tunnel_url(proc: subprocess.Popen, timeout: float = 15.0) -> Optional[str]:
|
|
849
|
-
if not proc.stdout:
|
|
850
|
-
return None
|
|
851
|
-
cf_re = re.compile(r"https://[A-Za-z0-9\-.]+\.cfargotunnel\.com")
|
|
852
|
-
deadline = time.time() + timeout
|
|
853
|
-
while time.time() < deadline:
|
|
854
|
-
ready, _, _ = select.select([proc.stdout], [], [], 1)
|
|
855
|
-
if not ready:
|
|
856
|
-
continue
|
|
857
|
-
line = proc.stdout.readline()
|
|
858
|
-
if not line:
|
|
859
|
-
continue
|
|
860
|
-
match = cf_re.search(line)
|
|
861
|
-
if match:
|
|
862
|
-
return match.group(0)
|
|
863
|
-
return None
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
def _start_cloudflare_process(name: str, target_url: str, hostname: Optional[str] = None) -> Tuple[subprocess.Popen, Optional[str]]:
|
|
867
|
-
binary = _get_cloudflared_binary()
|
|
868
|
-
cmd = [
|
|
869
|
-
binary,
|
|
870
|
-
"tunnel",
|
|
871
|
-
"--url",
|
|
872
|
-
target_url,
|
|
873
|
-
"--no-autoupdate",
|
|
874
|
-
]
|
|
875
|
-
if hostname:
|
|
876
|
-
cmd.extend(["--hostname", hostname])
|
|
877
|
-
stdout_target = subprocess.DEVNULL
|
|
878
|
-
else:
|
|
879
|
-
stdout_target = subprocess.PIPE
|
|
880
|
-
proc = subprocess.Popen(
|
|
881
|
-
cmd,
|
|
882
|
-
stdout=stdout_target,
|
|
883
|
-
stderr=subprocess.PIPE,
|
|
884
|
-
text=True,
|
|
885
|
-
)
|
|
886
|
-
with _CLOUDFLARE_TUNNELS_LOCK:
|
|
887
|
-
_CLOUDFLARE_TUNNEL_PROCESSES[name] = proc
|
|
888
|
-
assigned_url = hostname
|
|
889
|
-
if not hostname:
|
|
890
|
-
assigned_url = _await_quick_tunnel_url(proc)
|
|
891
|
-
threading.Thread(target=_drain_stream, args=(proc.stdout,), daemon=True).start()
|
|
892
|
-
threading.Thread(target=_drain_stream, args=(proc.stderr,), daemon=True).start()
|
|
893
|
-
return proc, assigned_url
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
def _stop_cloudflare_process(name: str) -> bool:
|
|
897
|
-
with _CLOUDFLARE_TUNNELS_LOCK:
|
|
898
|
-
proc = _CLOUDFLARE_TUNNEL_PROCESSES.pop(name, None)
|
|
899
|
-
if not proc:
|
|
900
|
-
return False
|
|
901
|
-
try:
|
|
902
|
-
proc.terminate()
|
|
903
|
-
proc.wait(timeout=5)
|
|
904
|
-
except subprocess.TimeoutExpired:
|
|
905
|
-
proc.kill()
|
|
906
|
-
proc.wait()
|
|
907
|
-
return True
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
def _resolve_container_ip(proxmox: Any, node: str, vmid: int) -> str:
|
|
911
|
-
status = proxmox.nodes(node).lxc(vmid).status.current.get()
|
|
912
|
-
if status:
|
|
913
|
-
ip_field = status.get("ip")
|
|
914
|
-
if isinstance(ip_field, list):
|
|
915
|
-
for entry in ip_field:
|
|
916
|
-
if isinstance(entry, str) and "." in entry:
|
|
917
|
-
return entry.split("/")[0]
|
|
918
|
-
elif isinstance(ip_field, str) and "." in ip_field:
|
|
919
|
-
return ip_field.split("/")[0]
|
|
920
|
-
res = _run_pct_exec(vmid, ["ip", "-4", "-o", "addr", "show", "eth0"])
|
|
921
|
-
line = res.stdout.splitlines()[0] if res.stdout else ""
|
|
922
|
-
parts = line.split()
|
|
923
|
-
if len(parts) >= 4:
|
|
924
|
-
addr = parts[3]
|
|
925
|
-
return addr.split("/")[0]
|
|
926
|
-
raise RuntimeError("Unable to determine container IP address")
|
|
927
|
-
|
|
928
|
-
|
|
929
713
|
def _push_bytes_to_container(
|
|
930
714
|
vmid: int, user: str, path: str, data: bytes, mode: int = 0o600
|
|
931
715
|
) -> None:
|
|
@@ -1224,12 +1008,6 @@ def _bootstrap_portacode(
|
|
|
1224
1008
|
return public_key, results
|
|
1225
1009
|
|
|
1226
1010
|
|
|
1227
|
-
def _build_cloudflare_snapshot(cloudflare_config: Dict[str, Any] | None) -> Dict[str, Any]:
|
|
1228
|
-
if not cloudflare_config:
|
|
1229
|
-
return {"configured": False}
|
|
1230
|
-
return {"configured": bool(cloudflare_config.get("api_token"))}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
1011
|
def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
1234
1012
|
network = config.get("network", {})
|
|
1235
1013
|
base_network = {
|
|
@@ -1237,13 +1015,8 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1237
1015
|
"message": network.get("message"),
|
|
1238
1016
|
"bridge": network.get("bridge", DEFAULT_BRIDGE),
|
|
1239
1017
|
}
|
|
1240
|
-
cloudflare_snapshot = _build_cloudflare_snapshot(config.get("cloudflare"))
|
|
1241
1018
|
if not config:
|
|
1242
|
-
return {
|
|
1243
|
-
"configured": False,
|
|
1244
|
-
"network": base_network,
|
|
1245
|
-
"cloudflare": cloudflare_snapshot,
|
|
1246
|
-
}
|
|
1019
|
+
return {"configured": False, "network": base_network}
|
|
1247
1020
|
return {
|
|
1248
1021
|
"configured": True,
|
|
1249
1022
|
"host": config.get("host"),
|
|
@@ -1254,54 +1027,18 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1254
1027
|
"templates": config.get("templates") or [],
|
|
1255
1028
|
"last_verified": config.get("last_verified"),
|
|
1256
1029
|
"network": base_network,
|
|
1257
|
-
"cloudflare": cloudflare_snapshot,
|
|
1258
1030
|
}
|
|
1259
1031
|
|
|
1260
1032
|
|
|
1261
|
-
def
|
|
1262
|
-
token_identifier: Optional[str],
|
|
1263
|
-
token_value: Optional[str],
|
|
1264
|
-
existing: Dict[str, Any],
|
|
1265
|
-
) -> Tuple[str, str, str]:
|
|
1266
|
-
if token_identifier:
|
|
1267
|
-
if not token_value:
|
|
1268
|
-
raise ValueError("token_value is required when providing a new token_identifier")
|
|
1269
|
-
user, token_name = _parse_token(token_identifier)
|
|
1270
|
-
return user, token_name, token_value
|
|
1271
|
-
if existing and existing.get("user") and existing.get("token_name") and existing.get("token_value"):
|
|
1272
|
-
return existing["user"], existing["token_name"], existing["token_value"]
|
|
1273
|
-
raise ValueError("Proxmox token identifier and value are required when no existing configuration is available")
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
def _build_cloudflare_config(existing: Dict[str, Any], api_token: Optional[str]) -> Dict[str, Any]:
|
|
1277
|
-
cloudflare: Dict[str, Any] = dict(existing.get("cloudflare", {}) or {})
|
|
1278
|
-
if api_token:
|
|
1279
|
-
cloudflare["api_token"] = api_token
|
|
1280
|
-
if cloudflare.get("api_token"):
|
|
1281
|
-
cloudflare["configured"] = True
|
|
1282
|
-
elif "configured" in cloudflare:
|
|
1283
|
-
cloudflare.pop("configured", None)
|
|
1284
|
-
return cloudflare
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
def configure_infrastructure(
|
|
1288
|
-
token_identifier: Optional[str] = None,
|
|
1289
|
-
token_value: Optional[str] = None,
|
|
1290
|
-
verify_ssl: Optional[bool] = None,
|
|
1291
|
-
cloudflare_api_token: Optional[str] = None,
|
|
1292
|
-
) -> Dict[str, Any]:
|
|
1033
|
+
def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl: bool = False) -> Dict[str, Any]:
|
|
1293
1034
|
ProxmoxAPI = _ensure_proxmoxer()
|
|
1294
|
-
|
|
1295
|
-
user, token_name, resolved_token_value = _resolve_proxmox_credentials(
|
|
1296
|
-
token_identifier, token_value, existing
|
|
1297
|
-
)
|
|
1298
|
-
actual_verify_ssl = verify_ssl if verify_ssl is not None else existing.get("verify_ssl", False)
|
|
1035
|
+
user, token_name = _parse_token(token_identifier)
|
|
1299
1036
|
client = ProxmoxAPI(
|
|
1300
1037
|
DEFAULT_HOST,
|
|
1301
1038
|
user=user,
|
|
1302
1039
|
token_name=token_name,
|
|
1303
|
-
token_value=
|
|
1304
|
-
verify_ssl=
|
|
1040
|
+
token_value=token_value,
|
|
1041
|
+
verify_ssl=verify_ssl,
|
|
1305
1042
|
timeout=30,
|
|
1306
1043
|
)
|
|
1307
1044
|
node = _pick_node(client)
|
|
@@ -1309,36 +1046,31 @@ def configure_infrastructure(
|
|
|
1309
1046
|
storages = client.nodes(node).storage.get()
|
|
1310
1047
|
default_storage = _pick_storage(storages)
|
|
1311
1048
|
templates = _list_templates(client, node, storages)
|
|
1312
|
-
network
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
_revert_bridge()
|
|
1325
|
-
raise
|
|
1049
|
+
network: Dict[str, Any] = {}
|
|
1050
|
+
try:
|
|
1051
|
+
network = _ensure_bridge()
|
|
1052
|
+
# Wait for network convergence before validating connectivity
|
|
1053
|
+
time.sleep(2)
|
|
1054
|
+
if not _verify_connectivity():
|
|
1055
|
+
raise RuntimeError("Connectivity check failed; bridge reverted")
|
|
1056
|
+
network["health"] = "healthy"
|
|
1057
|
+
except Exception as exc:
|
|
1058
|
+
logger.warning("Bridge setup failed; reverting previous changes: %s", exc)
|
|
1059
|
+
_revert_bridge()
|
|
1060
|
+
raise
|
|
1326
1061
|
config = {
|
|
1327
1062
|
"host": DEFAULT_HOST,
|
|
1328
1063
|
"node": node,
|
|
1329
1064
|
"user": user,
|
|
1330
1065
|
"token_name": token_name,
|
|
1331
|
-
"token_value":
|
|
1332
|
-
"verify_ssl":
|
|
1066
|
+
"token_value": token_value,
|
|
1067
|
+
"verify_ssl": verify_ssl,
|
|
1333
1068
|
"default_storage": default_storage,
|
|
1334
1069
|
"templates": templates,
|
|
1335
1070
|
"last_verified": datetime.utcnow().isoformat() + "Z",
|
|
1336
1071
|
"network": network,
|
|
1337
1072
|
"node_status": status,
|
|
1338
1073
|
}
|
|
1339
|
-
cloudflare = _build_cloudflare_config(existing, cloudflare_api_token)
|
|
1340
|
-
if cloudflare:
|
|
1341
|
-
config["cloudflare"] = cloudflare
|
|
1342
1074
|
_save_config(config)
|
|
1343
1075
|
snapshot = build_snapshot(config)
|
|
1344
1076
|
snapshot["node_status"] = status
|
|
@@ -1412,7 +1144,10 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1412
1144
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1413
1145
|
logger.info("create_proxmox_container command received")
|
|
1414
1146
|
request_id = message.get("request_id")
|
|
1415
|
-
|
|
1147
|
+
raw_device_id = message.get("device_id")
|
|
1148
|
+
device_id = str(raw_device_id or "").strip()
|
|
1149
|
+
if not device_id:
|
|
1150
|
+
raise ValueError("device_id is required to create a container")
|
|
1416
1151
|
device_public_key = (message.get("device_public_key") or "").strip()
|
|
1417
1152
|
device_private_key = (message.get("device_private_key") or "").strip()
|
|
1418
1153
|
has_device_keypair = bool(device_public_key and device_private_key)
|
|
@@ -1444,6 +1179,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1444
1179
|
message=start_message,
|
|
1445
1180
|
phase="lifecycle",
|
|
1446
1181
|
request_id=request_id,
|
|
1182
|
+
on_behalf_of_device=device_id,
|
|
1447
1183
|
)
|
|
1448
1184
|
try:
|
|
1449
1185
|
result = action()
|
|
@@ -1459,6 +1195,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1459
1195
|
phase="lifecycle",
|
|
1460
1196
|
request_id=request_id,
|
|
1461
1197
|
details={"error": str(exc)},
|
|
1198
|
+
on_behalf_of_device=device_id,
|
|
1462
1199
|
)
|
|
1463
1200
|
raise
|
|
1464
1201
|
_emit_progress_event(
|
|
@@ -1471,6 +1208,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1471
1208
|
message=success_message,
|
|
1472
1209
|
phase="lifecycle",
|
|
1473
1210
|
request_id=request_id,
|
|
1211
|
+
on_behalf_of_device=device_id,
|
|
1474
1212
|
)
|
|
1475
1213
|
current_step_index += 1
|
|
1476
1214
|
return result
|
|
@@ -1512,6 +1250,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1512
1250
|
payload["vmid"] = vmid
|
|
1513
1251
|
payload["created_at"] = datetime.utcnow().isoformat() + "Z"
|
|
1514
1252
|
payload["status"] = "creating"
|
|
1253
|
+
payload["device_id"] = device_id
|
|
1515
1254
|
_write_container_record(vmid, payload)
|
|
1516
1255
|
return proxmox, node, vmid, payload
|
|
1517
1256
|
|
|
@@ -1572,6 +1311,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1572
1311
|
phase="bootstrap",
|
|
1573
1312
|
request_id=request_id,
|
|
1574
1313
|
details=details or None,
|
|
1314
|
+
on_behalf_of_device=device_id,
|
|
1575
1315
|
)
|
|
1576
1316
|
|
|
1577
1317
|
public_key, steps = _bootstrap_portacode(
|
|
@@ -1615,6 +1355,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1615
1355
|
message="Notifying the server of the new device…",
|
|
1616
1356
|
phase="service",
|
|
1617
1357
|
request_id=request_id,
|
|
1358
|
+
on_behalf_of_device=device_id,
|
|
1618
1359
|
)
|
|
1619
1360
|
_emit_progress_event(
|
|
1620
1361
|
self,
|
|
@@ -1626,6 +1367,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1626
1367
|
message="Authentication metadata recorded.",
|
|
1627
1368
|
phase="service",
|
|
1628
1369
|
request_id=request_id,
|
|
1370
|
+
on_behalf_of_device=device_id,
|
|
1629
1371
|
)
|
|
1630
1372
|
|
|
1631
1373
|
install_step = service_start_index + 1
|
|
@@ -1640,6 +1382,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1640
1382
|
message="Running sudo portacode service install…",
|
|
1641
1383
|
phase="service",
|
|
1642
1384
|
request_id=request_id,
|
|
1385
|
+
on_behalf_of_device=device_id,
|
|
1643
1386
|
)
|
|
1644
1387
|
|
|
1645
1388
|
cmd = f"su - {payload['username']} -c 'sudo -S portacode service install'"
|
|
@@ -1660,6 +1403,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1660
1403
|
"stderr": res.get("stderr"),
|
|
1661
1404
|
"stdout": res.get("stdout"),
|
|
1662
1405
|
},
|
|
1406
|
+
on_behalf_of_device=device_id,
|
|
1663
1407
|
)
|
|
1664
1408
|
raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
|
|
1665
1409
|
|
|
@@ -1673,6 +1417,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1673
1417
|
message="Portacode service install finished.",
|
|
1674
1418
|
phase="service",
|
|
1675
1419
|
request_id=request_id,
|
|
1420
|
+
on_behalf_of_device=device_id,
|
|
1676
1421
|
)
|
|
1677
1422
|
|
|
1678
1423
|
logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
|
|
@@ -1696,6 +1441,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1696
1441
|
},
|
|
1697
1442
|
"setup_steps": steps,
|
|
1698
1443
|
"device_id": device_id,
|
|
1444
|
+
"on_behalf_of_device": device_id,
|
|
1699
1445
|
"service_installed": service_installed,
|
|
1700
1446
|
}
|
|
1701
1447
|
|
|
@@ -1721,6 +1467,9 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1721
1467
|
password = record.get("password")
|
|
1722
1468
|
if not user or not password:
|
|
1723
1469
|
raise RuntimeError("Container credentials unavailable")
|
|
1470
|
+
on_behalf_of_device = record.get("device_id")
|
|
1471
|
+
if on_behalf_of_device:
|
|
1472
|
+
on_behalf_of_device = str(on_behalf_of_device)
|
|
1724
1473
|
|
|
1725
1474
|
start_index = int(message.get("step_index", 1))
|
|
1726
1475
|
total_steps = int(message.get("total_steps", start_index + 2))
|
|
@@ -1738,6 +1487,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1738
1487
|
message="Notifying the server of the new device…",
|
|
1739
1488
|
phase="service",
|
|
1740
1489
|
request_id=request_id,
|
|
1490
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1741
1491
|
)
|
|
1742
1492
|
_emit_progress_event(
|
|
1743
1493
|
self,
|
|
@@ -1749,6 +1499,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1749
1499
|
message="Authentication metadata recorded.",
|
|
1750
1500
|
phase="service",
|
|
1751
1501
|
request_id=request_id,
|
|
1502
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1752
1503
|
)
|
|
1753
1504
|
|
|
1754
1505
|
install_step = start_index + 1
|
|
@@ -1763,6 +1514,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1763
1514
|
message="Running sudo portacode service install…",
|
|
1764
1515
|
phase="service",
|
|
1765
1516
|
request_id=request_id,
|
|
1517
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1766
1518
|
)
|
|
1767
1519
|
|
|
1768
1520
|
cmd = f"su - {user} -c 'sudo -S portacode service install'"
|
|
@@ -1783,6 +1535,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1783
1535
|
"stderr": res.get("stderr"),
|
|
1784
1536
|
"stdout": res.get("stdout"),
|
|
1785
1537
|
},
|
|
1538
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1786
1539
|
)
|
|
1787
1540
|
raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
|
|
1788
1541
|
|
|
@@ -1796,6 +1549,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1796
1549
|
message="Portacode service install finished.",
|
|
1797
1550
|
phase="service",
|
|
1798
1551
|
request_id=request_id,
|
|
1552
|
+
on_behalf_of_device=on_behalf_of_device,
|
|
1799
1553
|
)
|
|
1800
1554
|
|
|
1801
1555
|
return {
|
|
@@ -1822,10 +1576,6 @@ class StartProxmoxContainerHandler(SyncHandler):
|
|
|
1822
1576
|
|
|
1823
1577
|
status, elapsed = _start_container(proxmox, node, vmid)
|
|
1824
1578
|
_update_container_record(vmid, {"status": "running"})
|
|
1825
|
-
try:
|
|
1826
|
-
_ensure_container_tunnel_running(proxmox, node, vmid)
|
|
1827
|
-
except Exception as exc:
|
|
1828
|
-
raise RuntimeError(f"Failed to start Cloudflare tunnel for container {vmid}: {exc}") from exc
|
|
1829
1579
|
|
|
1830
1580
|
infra = get_infra_snapshot()
|
|
1831
1581
|
return {
|
|
@@ -1855,7 +1605,6 @@ class StopProxmoxContainerHandler(SyncHandler):
|
|
|
1855
1605
|
_ensure_container_managed(proxmox, node, vmid)
|
|
1856
1606
|
|
|
1857
1607
|
status, elapsed = _stop_container(proxmox, node, vmid)
|
|
1858
|
-
_stop_container_tunnel(vmid)
|
|
1859
1608
|
final_status = status.get("status") or "stopped"
|
|
1860
1609
|
_update_container_record(vmid, {"status": final_status})
|
|
1861
1610
|
|
|
@@ -1891,13 +1640,8 @@ class RemoveProxmoxContainerHandler(SyncHandler):
|
|
|
1891
1640
|
node = _get_node_from_config(config)
|
|
1892
1641
|
_ensure_container_managed(proxmox, node, vmid)
|
|
1893
1642
|
|
|
1894
|
-
_stop_container_tunnel(vmid)
|
|
1895
1643
|
stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
|
|
1896
1644
|
delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
|
|
1897
|
-
try:
|
|
1898
|
-
_update_container_tunnel(vmid, None)
|
|
1899
|
-
except FileNotFoundError:
|
|
1900
|
-
pass
|
|
1901
1645
|
_remove_container_record(vmid)
|
|
1902
1646
|
|
|
1903
1647
|
infra = get_infra_snapshot()
|
|
@@ -1916,134 +1660,6 @@ class RemoveProxmoxContainerHandler(SyncHandler):
|
|
|
1916
1660
|
}
|
|
1917
1661
|
|
|
1918
1662
|
|
|
1919
|
-
class CreateCloudflareTunnelHandler(SyncHandler):
|
|
1920
|
-
"""Create a Cloudflare tunnel for a container."""
|
|
1921
|
-
|
|
1922
|
-
@property
|
|
1923
|
-
def command_name(self) -> str:
|
|
1924
|
-
return "create_cloudflare_tunnel"
|
|
1925
|
-
|
|
1926
|
-
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1927
|
-
vmid = _parse_ctid(message)
|
|
1928
|
-
container_port = int(message.get("container_port") or 0)
|
|
1929
|
-
if container_port <= 0:
|
|
1930
|
-
raise ValueError("container_port is required and must be greater than zero.")
|
|
1931
|
-
hostname = (message.get("cloudflare_url") or message.get("hostname") or "").strip()
|
|
1932
|
-
hostname = hostname or None
|
|
1933
|
-
protocol = (message.get("protocol") or "http").strip().lower()
|
|
1934
|
-
if protocol not in {"http", "https", "tcp"}:
|
|
1935
|
-
raise ValueError("protocol must be one of http, https, or tcp.")
|
|
1936
|
-
config = _ensure_infra_configured()
|
|
1937
|
-
_ensure_cloudflare_token(config)
|
|
1938
|
-
proxmox = _connect_proxmox(config)
|
|
1939
|
-
node = _get_node_from_config(config)
|
|
1940
|
-
_ensure_container_managed(proxmox, node, vmid)
|
|
1941
|
-
status = proxmox.nodes(node).lxc(vmid).status.current.get().get("status")
|
|
1942
|
-
if status != "running":
|
|
1943
|
-
raise RuntimeError("Container must be running to create a tunnel.")
|
|
1944
|
-
tunnel = {
|
|
1945
|
-
"container_port": container_port,
|
|
1946
|
-
"protocol": protocol,
|
|
1947
|
-
}
|
|
1948
|
-
if hostname:
|
|
1949
|
-
tunnel["url"] = hostname
|
|
1950
|
-
created = _launch_container_tunnel(proxmox, node, vmid, tunnel)
|
|
1951
|
-
infra = get_infra_snapshot()
|
|
1952
|
-
host_url = created.get("url")
|
|
1953
|
-
response_message = f"Created Cloudflare tunnel for container {vmid}."
|
|
1954
|
-
if host_url:
|
|
1955
|
-
response_message = f"{response_message[:-1]} -> {host_url}."
|
|
1956
|
-
response = {
|
|
1957
|
-
"event": "cloudflare_tunnel_created",
|
|
1958
|
-
"ctid": str(vmid),
|
|
1959
|
-
"success": True,
|
|
1960
|
-
"message": response_message,
|
|
1961
|
-
"tunnel": created,
|
|
1962
|
-
"infra": infra,
|
|
1963
|
-
}
|
|
1964
|
-
device_id = message.get("device_id")
|
|
1965
|
-
if device_id:
|
|
1966
|
-
response["device_id"] = device_id
|
|
1967
|
-
return response
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
class UpdateCloudflareTunnelHandler(SyncHandler):
|
|
1971
|
-
"""Update an existing Cloudflare tunnel for a container."""
|
|
1972
|
-
|
|
1973
|
-
@property
|
|
1974
|
-
def command_name(self) -> str:
|
|
1975
|
-
return "update_cloudflare_tunnel"
|
|
1976
|
-
|
|
1977
|
-
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1978
|
-
vmid = _parse_ctid(message)
|
|
1979
|
-
config = _ensure_infra_configured()
|
|
1980
|
-
_ensure_cloudflare_token(config)
|
|
1981
|
-
proxmox = _connect_proxmox(config)
|
|
1982
|
-
node = _get_node_from_config(config)
|
|
1983
|
-
_ensure_container_managed(proxmox, node, vmid)
|
|
1984
|
-
record = _read_container_record(vmid)
|
|
1985
|
-
tunnel = record.get("tunnel")
|
|
1986
|
-
if not tunnel:
|
|
1987
|
-
raise RuntimeError("No Cloudflare tunnel configured for this container.")
|
|
1988
|
-
container_port = int(message.get("container_port") or tunnel.get("container_port") or 0)
|
|
1989
|
-
if container_port <= 0:
|
|
1990
|
-
raise ValueError("container_port must be greater than zero.")
|
|
1991
|
-
hostname = (message.get("cloudflare_url") or tunnel.get("url") or "").strip()
|
|
1992
|
-
hostname = hostname or None
|
|
1993
|
-
protocol = (message.get("protocol") or tunnel.get("protocol") or "http").strip().lower()
|
|
1994
|
-
if protocol not in {"http", "https", "tcp"}:
|
|
1995
|
-
raise ValueError("protocol must be one of http, https, or tcp.")
|
|
1996
|
-
updated_tunnel = {
|
|
1997
|
-
"container_port": container_port,
|
|
1998
|
-
"protocol": protocol,
|
|
1999
|
-
}
|
|
2000
|
-
if hostname:
|
|
2001
|
-
updated_tunnel["url"] = hostname
|
|
2002
|
-
result = _launch_container_tunnel(proxmox, node, vmid, updated_tunnel)
|
|
2003
|
-
infra = get_infra_snapshot()
|
|
2004
|
-
host_url = result.get("url")
|
|
2005
|
-
response_message = f"Updated Cloudflare tunnel for container {vmid}."
|
|
2006
|
-
if host_url:
|
|
2007
|
-
response_message = f"{response_message[:-1]} -> {host_url}."
|
|
2008
|
-
response = {
|
|
2009
|
-
"event": "cloudflare_tunnel_updated",
|
|
2010
|
-
"ctid": str(vmid),
|
|
2011
|
-
"success": True,
|
|
2012
|
-
"message": response_message,
|
|
2013
|
-
"tunnel": result,
|
|
2014
|
-
"infra": infra,
|
|
2015
|
-
}
|
|
2016
|
-
device_id = message.get("device_id")
|
|
2017
|
-
if device_id:
|
|
2018
|
-
response["device_id"] = device_id
|
|
2019
|
-
return response
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
class RemoveCloudflareTunnelHandler(SyncHandler):
|
|
2023
|
-
"""Remove any Cloudflare tunnel associated with a container."""
|
|
2024
|
-
|
|
2025
|
-
@property
|
|
2026
|
-
def command_name(self) -> str:
|
|
2027
|
-
return "remove_cloudflare_tunnel"
|
|
2028
|
-
|
|
2029
|
-
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
2030
|
-
vmid = _parse_ctid(message)
|
|
2031
|
-
_remove_container_tunnel_state(vmid)
|
|
2032
|
-
infra = get_infra_snapshot()
|
|
2033
|
-
response = {
|
|
2034
|
-
"event": "cloudflare_tunnel_removed",
|
|
2035
|
-
"ctid": str(vmid),
|
|
2036
|
-
"success": True,
|
|
2037
|
-
"message": f"Removed Cloudflare tunnel state for container {vmid}.",
|
|
2038
|
-
"tunnel": None,
|
|
2039
|
-
"infra": infra,
|
|
2040
|
-
}
|
|
2041
|
-
device_id = message.get("device_id")
|
|
2042
|
-
if device_id:
|
|
2043
|
-
response["device_id"] = device_id
|
|
2044
|
-
return response
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
1663
|
class ConfigureProxmoxInfraHandler(SyncHandler):
|
|
2048
1664
|
@property
|
|
2049
1665
|
def command_name(self) -> str:
|
|
@@ -2052,13 +1668,10 @@ class ConfigureProxmoxInfraHandler(SyncHandler):
|
|
|
2052
1668
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
2053
1669
|
token_identifier = message.get("token_identifier")
|
|
2054
1670
|
token_value = message.get("token_value")
|
|
2055
|
-
verify_ssl = message.get("verify_ssl")
|
|
2056
|
-
|
|
2057
|
-
token_identifier
|
|
2058
|
-
|
|
2059
|
-
verify_ssl=verify_ssl,
|
|
2060
|
-
cloudflare_api_token=message.get("cloudflare_api_token"),
|
|
2061
|
-
)
|
|
1671
|
+
verify_ssl = bool(message.get("verify_ssl"))
|
|
1672
|
+
if not token_identifier or not token_value:
|
|
1673
|
+
raise ValueError("token_identifier and token_value are required")
|
|
1674
|
+
snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
|
|
2062
1675
|
return {
|
|
2063
1676
|
"event": "proxmox_infra_configured",
|
|
2064
1677
|
"success": True,
|
portacode/connection/terminal.py
CHANGED
|
@@ -53,14 +53,11 @@ from .handlers import (
|
|
|
53
53
|
ProjectStateGitCommitHandler,
|
|
54
54
|
UpdatePortacodeHandler,
|
|
55
55
|
ConfigureProxmoxInfraHandler,
|
|
56
|
-
CreateCloudflareTunnelHandler,
|
|
57
56
|
CreateProxmoxContainerHandler,
|
|
58
|
-
RemoveCloudflareTunnelHandler,
|
|
59
57
|
RevertProxmoxInfraHandler,
|
|
60
58
|
StartPortacodeServiceHandler,
|
|
61
59
|
StartProxmoxContainerHandler,
|
|
62
60
|
StopProxmoxContainerHandler,
|
|
63
|
-
UpdateCloudflareTunnelHandler,
|
|
64
61
|
RemoveProxmoxContainerHandler,
|
|
65
62
|
)
|
|
66
63
|
from .handlers.project_aware_file_handlers import (
|
|
@@ -485,14 +482,11 @@ class TerminalManager:
|
|
|
485
482
|
# System management handlers
|
|
486
483
|
self._command_registry.register(ConfigureProxmoxInfraHandler)
|
|
487
484
|
self._command_registry.register(CreateProxmoxContainerHandler)
|
|
488
|
-
self._command_registry.register(CreateCloudflareTunnelHandler)
|
|
489
485
|
self._command_registry.register(StartPortacodeServiceHandler)
|
|
490
486
|
self._command_registry.register(StartProxmoxContainerHandler)
|
|
491
487
|
self._command_registry.register(StopProxmoxContainerHandler)
|
|
492
488
|
self._command_registry.register(RemoveProxmoxContainerHandler)
|
|
493
|
-
self._command_registry.register(UpdateCloudflareTunnelHandler)
|
|
494
489
|
self._command_registry.register(RevertProxmoxInfraHandler)
|
|
495
|
-
self._command_registry.register(RemoveCloudflareTunnelHandler)
|
|
496
490
|
self._command_registry.register(UpdatePortacodeHandler)
|
|
497
491
|
|
|
498
492
|
# ---------------------------------------------------------------------
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
|
|
2
2
|
portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
|
|
3
3
|
portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
|
|
4
|
-
portacode/_version.py,sha256=
|
|
4
|
+
portacode/_version.py,sha256=VfdRURf59q24xrxCKaIpQKaRdnFEAgLbc9xW5UpNolM,719
|
|
5
5
|
portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
|
|
6
6
|
portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
|
|
7
7
|
portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
|
|
@@ -12,17 +12,17 @@ portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJ
|
|
|
12
12
|
portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
|
|
13
13
|
portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
|
|
14
14
|
portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
|
|
15
|
-
portacode/connection/terminal.py,sha256=
|
|
15
|
+
portacode/connection/terminal.py,sha256=oyLPOVLPlUuN_eRvHPGazB51yi8W8JEF3oOEYxucGTE,45069
|
|
16
16
|
portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
|
|
17
|
-
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=
|
|
18
|
-
portacode/connection/handlers/__init__.py,sha256=
|
|
17
|
+
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=OtObmoVAXlf0uU1HidTWNmyJYBS1Yl6Rpgyh6TOTjUQ,100590
|
|
18
|
+
portacode/connection/handlers/__init__.py,sha256=WSeBmi65GWFQPYt9M3E10rn0uZ_EPCJzNJOzSf2HZyw,2921
|
|
19
19
|
portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
|
|
20
20
|
portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
|
|
21
21
|
portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZbQw6sF04M3dM6rUV8Q,24477
|
|
22
22
|
portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
|
|
23
23
|
portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
|
|
24
24
|
portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
|
|
25
|
-
portacode/connection/handlers/proxmox_infra.py,sha256=
|
|
25
|
+
portacode/connection/handlers/proxmox_infra.py,sha256=dLKV2Epd7VCL89EmV_DalUOsxsTuLbnJopjPRbmTzUg,62370
|
|
26
26
|
portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
|
|
27
27
|
portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
|
|
28
28
|
portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
|
|
@@ -65,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
|
|
|
65
65
|
portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
|
|
66
66
|
portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
|
|
67
67
|
portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
|
|
68
|
-
portacode-1.4.15.
|
|
68
|
+
portacode-1.4.15.dev6.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
|
|
69
69
|
test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
|
|
70
70
|
test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
|
|
71
71
|
test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
|
|
@@ -91,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
|
|
|
91
91
|
testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
|
|
92
92
|
testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
|
|
93
93
|
testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
|
|
94
|
-
portacode-1.4.15.
|
|
95
|
-
portacode-1.4.15.
|
|
96
|
-
portacode-1.4.15.
|
|
97
|
-
portacode-1.4.15.
|
|
98
|
-
portacode-1.4.15.
|
|
94
|
+
portacode-1.4.15.dev6.dist-info/METADATA,sha256=KusMfGXKhY6aEbqR2olZ2VDfrPYPOvHlCzhpck5myKk,13051
|
|
95
|
+
portacode-1.4.15.dev6.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
96
|
+
portacode-1.4.15.dev6.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
|
|
97
|
+
portacode-1.4.15.dev6.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
|
|
98
|
+
portacode-1.4.15.dev6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|