cubepod 0.4.0__tar.gz
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.
- cubepod-0.4.0/.gitignore +60 -0
- cubepod-0.4.0/PKG-INFO +103 -0
- cubepod-0.4.0/README.md +76 -0
- cubepod-0.4.0/cubepod/__init__.py +33 -0
- cubepod-0.4.0/cubepod/_helpers.py +306 -0
- cubepod-0.4.0/cubepod/async_client.py +768 -0
- cubepod-0.4.0/cubepod/exceptions.py +5 -0
- cubepod-0.4.0/cubepod/models.py +103 -0
- cubepod-0.4.0/cubepod/py.typed +0 -0
- cubepod-0.4.0/cubepod/sync_client.py +771 -0
- cubepod-0.4.0/pyproject.toml +42 -0
- cubepod-0.4.0/tests/__init__.py +0 -0
- cubepod-0.4.0/tests/test_e2e_live.py +191 -0
- cubepod-0.4.0/tests/test_sdk.py +1430 -0
cubepod-0.4.0/.gitignore
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Binaries and build artifacts
|
|
2
|
+
bin/
|
|
3
|
+
dist/
|
|
4
|
+
target/
|
|
5
|
+
*.exe
|
|
6
|
+
*.exe~
|
|
7
|
+
*.dll
|
|
8
|
+
*.so
|
|
9
|
+
*.dylib
|
|
10
|
+
*.test
|
|
11
|
+
*.out
|
|
12
|
+
coverage.txt
|
|
13
|
+
coverage.xml
|
|
14
|
+
|
|
15
|
+
# Go workspace / vendor
|
|
16
|
+
go.work
|
|
17
|
+
go.work.sum
|
|
18
|
+
|
|
19
|
+
# Python
|
|
20
|
+
__pycache__/
|
|
21
|
+
*.py[cod]
|
|
22
|
+
*$py.class
|
|
23
|
+
.venv/
|
|
24
|
+
venv/
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
.mypy_cache/
|
|
27
|
+
.ruff_cache/
|
|
28
|
+
*.egg-info/
|
|
29
|
+
.eggs/
|
|
30
|
+
|
|
31
|
+
# Node / TypeScript
|
|
32
|
+
node_modules/
|
|
33
|
+
.pnpm-store/
|
|
34
|
+
*.tsbuildinfo
|
|
35
|
+
|
|
36
|
+
# Local development & runtime artifacts
|
|
37
|
+
.env
|
|
38
|
+
.env.*
|
|
39
|
+
!.env.example
|
|
40
|
+
deploy/k8s/env.local
|
|
41
|
+
*.img
|
|
42
|
+
*.qcow2
|
|
43
|
+
*.raw
|
|
44
|
+
tmp/
|
|
45
|
+
logs/
|
|
46
|
+
.cubepod-data/
|
|
47
|
+
|
|
48
|
+
# Kubernetes / Helm secrets
|
|
49
|
+
deploy/helm/cubepod/values.local.yaml
|
|
50
|
+
*ca-secret.yaml
|
|
51
|
+
*.pem
|
|
52
|
+
*.key
|
|
53
|
+
*.crt
|
|
54
|
+
|
|
55
|
+
# IDE / OS
|
|
56
|
+
.idea/
|
|
57
|
+
.vscode/
|
|
58
|
+
*.swp
|
|
59
|
+
*.swo
|
|
60
|
+
.DS_Store
|
cubepod-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cubepod
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Async-first Python SDK for CubePod (E2B-compatible persistent MicroVM sandboxes)
|
|
5
|
+
Project-URL: Homepage, https://github.com/cubeplexai/cubepod
|
|
6
|
+
Project-URL: Repository, https://github.com/cubeplexai/cubepod
|
|
7
|
+
Project-URL: Issues, https://github.com/cubeplexai/cubepod/issues
|
|
8
|
+
Author: CubePlex Maintainers
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: ai-agents,cubepod,e2b,kata-containers,microvm,sandbox
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: httpx>=0.27.0
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: e2b-code-interpreter>=2.0.0; extra == 'test'
|
|
23
|
+
Requires-Dist: e2b>=2.0.0; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# CubePod Python SDK
|
|
29
|
+
|
|
30
|
+
Async-first Python client for **CubePod** — a persistent, writable-RootFS MicroVM execution platform for AI agents with an E2B-compatible Control & Data Plane API.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install cubepod
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.11+.
|
|
39
|
+
|
|
40
|
+
## Quickstart (Async & Sync)
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from cubepod import AsyncCubePod, CubePod
|
|
45
|
+
|
|
46
|
+
async def main() -> None:
|
|
47
|
+
async with AsyncCubePod(base_url="http://localhost:8080", api_key="...") as client:
|
|
48
|
+
box = await client.create("cubeplex-ubuntu24-desktop")
|
|
49
|
+
res = await client.execute(box.sandbox_id, "python3 --version")
|
|
50
|
+
print(res.output)
|
|
51
|
+
await client.delete(box.sandbox_id)
|
|
52
|
+
|
|
53
|
+
asyncio.run(main())
|
|
54
|
+
|
|
55
|
+
# Synchronous client
|
|
56
|
+
with CubePod(base_url="http://localhost:8080", api_key="...") as client:
|
|
57
|
+
box = client.create("cubeplex-ubuntu24-desktop")
|
|
58
|
+
print(client.execute(box.sandbox_id, "uname -a").output)
|
|
59
|
+
client.delete(box.sandbox_id)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Command Execution & Cursor Logs
|
|
63
|
+
|
|
64
|
+
- **Foreground**: `await client.execute(sandbox_id, "echo hello", workdir="/workspace", envs={"FOO": "bar"})`
|
|
65
|
+
- **Background & Incremental Polling**:
|
|
66
|
+
```python
|
|
67
|
+
cmd = await client.start_command(sandbox_id, "for i in 1 2 3; do echo $i; sleep 1; done")
|
|
68
|
+
logs = await client.poll_command(sandbox_id, cmd.id, cursor=0)
|
|
69
|
+
# Or stream until completion:
|
|
70
|
+
async for chunk in client.stream_command(sandbox_id, cmd.id):
|
|
71
|
+
print(chunk.content, end="")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Files & Streaming Archive Sync
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
await client.write_file(sandbox_id, "/workspace/hello.txt", "hello cubepod\n")
|
|
78
|
+
|
|
79
|
+
# Stream large files incrementally
|
|
80
|
+
stream = await client.stream_file(sandbox_id, "/workspace/hello.txt")
|
|
81
|
+
async for chunk in stream:
|
|
82
|
+
print(chunk)
|
|
83
|
+
|
|
84
|
+
# Atomic tar.gz directory sync into the sandbox
|
|
85
|
+
await client.archive_sync(
|
|
86
|
+
sandbox_id,
|
|
87
|
+
{"app/main.py": "print('synced')\n"},
|
|
88
|
+
dest="/workspace",
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Persistent Lifecycle, Reset & 1s CoW Fork
|
|
93
|
+
|
|
94
|
+
CubePod decouples the container RootFS `upperdir` from `/workspace`:
|
|
95
|
+
|
|
96
|
+
- `await client.pause(sandbox_id)` — suspend compute while retaining both RootFS Upper and Workspace block PVCs.
|
|
97
|
+
- `await client.connect(sandbox_id)` — resume a paused sandbox automatically.
|
|
98
|
+
- `await client.reset_rootfs(sandbox_id)` — wipe RootFS `upperdir` changes while keeping `/workspace` intact.
|
|
99
|
+
- `await client.fork(sandbox_id)` — create a Copy-on-Write clone of both RootFS and Workspace volumes.
|
|
100
|
+
|
|
101
|
+
## Zero-Trust `cpodref_` Egress
|
|
102
|
+
|
|
103
|
+
Never inject raw API credentials into agent sandboxes. Pass `secret_bindings` with `cpodref_` placeholders at `create(...)` time; `cubepod-egress` swaps placeholders for real credentials over per-sandbox mTLS only when outbound requests leave the sandbox toward allowed target hosts.
|
cubepod-0.4.0/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# CubePod Python SDK
|
|
2
|
+
|
|
3
|
+
Async-first Python client for **CubePod** — a persistent, writable-RootFS MicroVM execution platform for AI agents with an E2B-compatible Control & Data Plane API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cubepod
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.11+.
|
|
12
|
+
|
|
13
|
+
## Quickstart (Async & Sync)
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
from cubepod import AsyncCubePod, CubePod
|
|
18
|
+
|
|
19
|
+
async def main() -> None:
|
|
20
|
+
async with AsyncCubePod(base_url="http://localhost:8080", api_key="...") as client:
|
|
21
|
+
box = await client.create("cubeplex-ubuntu24-desktop")
|
|
22
|
+
res = await client.execute(box.sandbox_id, "python3 --version")
|
|
23
|
+
print(res.output)
|
|
24
|
+
await client.delete(box.sandbox_id)
|
|
25
|
+
|
|
26
|
+
asyncio.run(main())
|
|
27
|
+
|
|
28
|
+
# Synchronous client
|
|
29
|
+
with CubePod(base_url="http://localhost:8080", api_key="...") as client:
|
|
30
|
+
box = client.create("cubeplex-ubuntu24-desktop")
|
|
31
|
+
print(client.execute(box.sandbox_id, "uname -a").output)
|
|
32
|
+
client.delete(box.sandbox_id)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Command Execution & Cursor Logs
|
|
36
|
+
|
|
37
|
+
- **Foreground**: `await client.execute(sandbox_id, "echo hello", workdir="/workspace", envs={"FOO": "bar"})`
|
|
38
|
+
- **Background & Incremental Polling**:
|
|
39
|
+
```python
|
|
40
|
+
cmd = await client.start_command(sandbox_id, "for i in 1 2 3; do echo $i; sleep 1; done")
|
|
41
|
+
logs = await client.poll_command(sandbox_id, cmd.id, cursor=0)
|
|
42
|
+
# Or stream until completion:
|
|
43
|
+
async for chunk in client.stream_command(sandbox_id, cmd.id):
|
|
44
|
+
print(chunk.content, end="")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Files & Streaming Archive Sync
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
await client.write_file(sandbox_id, "/workspace/hello.txt", "hello cubepod\n")
|
|
51
|
+
|
|
52
|
+
# Stream large files incrementally
|
|
53
|
+
stream = await client.stream_file(sandbox_id, "/workspace/hello.txt")
|
|
54
|
+
async for chunk in stream:
|
|
55
|
+
print(chunk)
|
|
56
|
+
|
|
57
|
+
# Atomic tar.gz directory sync into the sandbox
|
|
58
|
+
await client.archive_sync(
|
|
59
|
+
sandbox_id,
|
|
60
|
+
{"app/main.py": "print('synced')\n"},
|
|
61
|
+
dest="/workspace",
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Persistent Lifecycle, Reset & 1s CoW Fork
|
|
66
|
+
|
|
67
|
+
CubePod decouples the container RootFS `upperdir` from `/workspace`:
|
|
68
|
+
|
|
69
|
+
- `await client.pause(sandbox_id)` — suspend compute while retaining both RootFS Upper and Workspace block PVCs.
|
|
70
|
+
- `await client.connect(sandbox_id)` — resume a paused sandbox automatically.
|
|
71
|
+
- `await client.reset_rootfs(sandbox_id)` — wipe RootFS `upperdir` changes while keeping `/workspace` intact.
|
|
72
|
+
- `await client.fork(sandbox_id)` — create a Copy-on-Write clone of both RootFS and Workspace volumes.
|
|
73
|
+
|
|
74
|
+
## Zero-Trust `cpodref_` Egress
|
|
75
|
+
|
|
76
|
+
Never inject raw API credentials into agent sandboxes. Pass `secret_bindings` with `cpodref_` placeholders at `create(...)` time; `cubepod-egress` swaps placeholders for real credentials over per-sandbox mTLS only when outbound requests leave the sandbox toward allowed target hosts.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""CubePod Python SDK — Sync & Async client for persistent MicroVM agent sandboxes."""
|
|
2
|
+
|
|
3
|
+
from .async_client import AsyncCubePod
|
|
4
|
+
from .exceptions import CubePodFileTooLargeError
|
|
5
|
+
from .models import (
|
|
6
|
+
CommandInfo,
|
|
7
|
+
CommandLogs,
|
|
8
|
+
DesktopSession,
|
|
9
|
+
ExecutionResult,
|
|
10
|
+
FileEntry,
|
|
11
|
+
PanelEndpoint,
|
|
12
|
+
SandboxInfo,
|
|
13
|
+
_build_tar_gz_bytes,
|
|
14
|
+
)
|
|
15
|
+
from .sync_client import CubePod, SyncCubePod
|
|
16
|
+
|
|
17
|
+
__version__ = "0.4.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
"SandboxInfo",
|
|
22
|
+
"ExecutionResult",
|
|
23
|
+
"CommandInfo",
|
|
24
|
+
"CommandLogs",
|
|
25
|
+
"PanelEndpoint",
|
|
26
|
+
"FileEntry",
|
|
27
|
+
"DesktopSession",
|
|
28
|
+
"CubePodFileTooLargeError",
|
|
29
|
+
"_build_tar_gz_bytes",
|
|
30
|
+
"AsyncCubePod",
|
|
31
|
+
"CubePod",
|
|
32
|
+
"SyncCubePod",
|
|
33
|
+
]
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Shared payload builders and response parsers for sync and async CubePod clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .models import (
|
|
11
|
+
CommandInfo,
|
|
12
|
+
CommandLogs,
|
|
13
|
+
DesktopSession,
|
|
14
|
+
ExecutionResult,
|
|
15
|
+
FileEntry,
|
|
16
|
+
PanelEndpoint,
|
|
17
|
+
SandboxInfo,
|
|
18
|
+
_build_tar_gz_bytes,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_base_url(base_url: str) -> str:
|
|
23
|
+
normalized = base_url.strip().rstrip("/")
|
|
24
|
+
if normalized and not normalized.startswith(("http://", "https://")):
|
|
25
|
+
normalized = f"http://{normalized}"
|
|
26
|
+
return normalized
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _json_dict(resp: httpx.Response) -> dict[str, Any]:
|
|
30
|
+
return cast(dict[str, Any], resp.json())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _json_list(resp: httpx.Response, wrapper_key: str) -> list[dict[str, Any]]:
|
|
34
|
+
data = resp.json()
|
|
35
|
+
if isinstance(data, list):
|
|
36
|
+
return cast(list[dict[str, Any]], data)
|
|
37
|
+
return cast(list[dict[str, Any]], data.get(wrapper_key, []))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_info(data: dict[str, Any]) -> SandboxInfo:
|
|
41
|
+
raw_metadata = data.get("metadata")
|
|
42
|
+
metadata: dict[str, str] = (
|
|
43
|
+
{str(k): str(v) for k, v in raw_metadata.items()}
|
|
44
|
+
if isinstance(raw_metadata, dict)
|
|
45
|
+
else {}
|
|
46
|
+
)
|
|
47
|
+
envd_port_raw = data.get("envdPort")
|
|
48
|
+
rootfs_gen_raw = data.get("rootfsGeneration")
|
|
49
|
+
return SandboxInfo(
|
|
50
|
+
sandbox_id=str(data["sandboxID"]),
|
|
51
|
+
template_id=str(data["templateID"]),
|
|
52
|
+
state=str(data["state"]),
|
|
53
|
+
envd_port=int(envd_port_raw) if envd_port_raw is not None else 49983,
|
|
54
|
+
rootfs_generation=int(rootfs_gen_raw) if rootfs_gen_raw is not None else 1,
|
|
55
|
+
metadata=metadata,
|
|
56
|
+
raw=dict(data),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_panel_endpoint(data: dict[str, Any]) -> PanelEndpoint:
|
|
61
|
+
return PanelEndpoint(
|
|
62
|
+
url=str(data["url"]),
|
|
63
|
+
token=str(data["token"]),
|
|
64
|
+
expires_at=str(data["expiresAt"]) if data.get("expiresAt") is not None else None,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_desktop_session(data: dict[str, Any]) -> DesktopSession:
|
|
69
|
+
cdp_raw = data.get("cdpUrl") if "cdpUrl" in data else data.get("cdp_url")
|
|
70
|
+
expires_raw = data.get("expiresAt") if "expiresAt" in data else data.get("expires_at")
|
|
71
|
+
return DesktopSession(
|
|
72
|
+
status=str(data.get("status", "")),
|
|
73
|
+
desktop_url=str(data.get("desktopUrl") or data.get("desktop_url") or ""),
|
|
74
|
+
terminal_url=str(data.get("terminalUrl") or data.get("terminal_url") or ""),
|
|
75
|
+
cdp_url=str(cdp_raw) if cdp_raw is not None else None,
|
|
76
|
+
expires_at=str(expires_raw) if expires_raw is not None else None,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_execution_result(data: dict[str, Any]) -> ExecutionResult:
|
|
81
|
+
exit_code = data.get("exit_code") if "exit_code" in data else data.get("exitCode")
|
|
82
|
+
output = data.get("output", "") or data.get("stdout", "")
|
|
83
|
+
cmd_id = data.get("id") or data.get("cmdID", "")
|
|
84
|
+
return ExecutionResult(
|
|
85
|
+
id=str(cmd_id),
|
|
86
|
+
exit_code=int(exit_code) if exit_code is not None else None,
|
|
87
|
+
output=str(output),
|
|
88
|
+
running=bool(data.get("running", False)),
|
|
89
|
+
pid=int(data["pid"]) if data.get("pid") is not None else None,
|
|
90
|
+
cursor=int(data.get("cursor", 0)),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_command_info(
|
|
95
|
+
data: dict[str, Any],
|
|
96
|
+
*,
|
|
97
|
+
default_id: str = "",
|
|
98
|
+
default_running: bool = False,
|
|
99
|
+
) -> CommandInfo:
|
|
100
|
+
cmd_id = data.get("id") or data.get("cmdID", default_id)
|
|
101
|
+
exit_code = data.get("exit_code") if "exit_code" in data else data.get("exitCode")
|
|
102
|
+
output = data.get("output", "") or data.get("stdout", "")
|
|
103
|
+
return CommandInfo(
|
|
104
|
+
id=str(cmd_id),
|
|
105
|
+
pid=int(data["pid"]) if data.get("pid") is not None else None,
|
|
106
|
+
running=bool(data.get("running", default_running)),
|
|
107
|
+
exit_code=int(exit_code) if exit_code is not None else None,
|
|
108
|
+
cursor=int(data.get("cursor", 0)),
|
|
109
|
+
output=str(output),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_command_logs(
|
|
114
|
+
data: dict[str, Any],
|
|
115
|
+
*,
|
|
116
|
+
command_id: str,
|
|
117
|
+
default_cursor: int,
|
|
118
|
+
) -> CommandLogs:
|
|
119
|
+
cmd_id = data.get("id") or data.get("cmdID", command_id)
|
|
120
|
+
exit_code = data.get("exit_code") if "exit_code" in data else data.get("exitCode")
|
|
121
|
+
return CommandLogs(
|
|
122
|
+
id=str(cmd_id),
|
|
123
|
+
content=str(data.get("content", "")),
|
|
124
|
+
cursor=int(data.get("cursor", default_cursor)),
|
|
125
|
+
running=bool(data.get("running", False)),
|
|
126
|
+
exit_code=int(exit_code) if exit_code is not None else None,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_file_entries(data: Any) -> list[FileEntry]:
|
|
131
|
+
entries = data if isinstance(data, list) else data.get("entries", [])
|
|
132
|
+
result: list[FileEntry] = []
|
|
133
|
+
for e in entries:
|
|
134
|
+
raw_mod = e.get("modified_at") or e.get("modifiedAt") or e.get("mtime")
|
|
135
|
+
result.append(
|
|
136
|
+
FileEntry(
|
|
137
|
+
name=str(e.get("name", "")),
|
|
138
|
+
path=str(e.get("path", "")),
|
|
139
|
+
is_dir=bool(e.get("is_dir", False)),
|
|
140
|
+
size=int(e.get("size", 0)),
|
|
141
|
+
mode=str(e.get("mode", "0644")),
|
|
142
|
+
modified_at=str(raw_mod) if raw_mod is not None else None,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_create_payload(
|
|
149
|
+
template_id: str,
|
|
150
|
+
*,
|
|
151
|
+
volume_key: str | None = None,
|
|
152
|
+
metadata: dict[str, str] | None = None,
|
|
153
|
+
network_policy: dict[str, Any] | None = None,
|
|
154
|
+
secret_bindings: list[dict[str, Any]] | None = None,
|
|
155
|
+
timeout_seconds: int | None = None,
|
|
156
|
+
warm_pool_ref: str | None = None,
|
|
157
|
+
resource: dict[str, str] | None = None,
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
payload: dict[str, Any] = {"templateID": template_id, "metadata": metadata or {}}
|
|
160
|
+
if timeout_seconds is not None:
|
|
161
|
+
payload["timeoutSeconds"] = timeout_seconds
|
|
162
|
+
|
|
163
|
+
ext: dict[str, Any] = {}
|
|
164
|
+
if volume_key:
|
|
165
|
+
ext["volumeKey"] = volume_key
|
|
166
|
+
if warm_pool_ref:
|
|
167
|
+
ext["warmPoolRef"] = warm_pool_ref
|
|
168
|
+
if resource:
|
|
169
|
+
ext["resource"] = resource
|
|
170
|
+
if ext:
|
|
171
|
+
payload["extension"] = ext
|
|
172
|
+
|
|
173
|
+
if network_policy is not None:
|
|
174
|
+
payload["networkPolicy"] = dict(network_policy)
|
|
175
|
+
if secret_bindings is not None:
|
|
176
|
+
if "networkPolicy" not in payload:
|
|
177
|
+
payload["networkPolicy"] = {}
|
|
178
|
+
payload["networkPolicy"]["secretBindings"] = secret_bindings
|
|
179
|
+
return payload
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _build_fork_payload(
|
|
183
|
+
*,
|
|
184
|
+
volume_key: str | None = None,
|
|
185
|
+
metadata: dict[str, str] | None = None,
|
|
186
|
+
) -> dict[str, Any] | None:
|
|
187
|
+
payload: dict[str, Any] = {}
|
|
188
|
+
if metadata is not None:
|
|
189
|
+
payload["metadata"] = metadata
|
|
190
|
+
if volume_key:
|
|
191
|
+
payload["extension"] = {"volumeKey": volume_key}
|
|
192
|
+
return payload or None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _build_command_payload(
|
|
196
|
+
command: str,
|
|
197
|
+
*,
|
|
198
|
+
background: bool,
|
|
199
|
+
workdir: str | None = None,
|
|
200
|
+
envs: dict[str, str] | None = None,
|
|
201
|
+
uid: int | None = 1000,
|
|
202
|
+
gid: int | None = 1000,
|
|
203
|
+
) -> dict[str, Any]:
|
|
204
|
+
payload: dict[str, Any] = {
|
|
205
|
+
"command": command,
|
|
206
|
+
"background": background,
|
|
207
|
+
}
|
|
208
|
+
if uid is not None:
|
|
209
|
+
payload["uid"] = uid
|
|
210
|
+
if gid is not None:
|
|
211
|
+
payload["gid"] = gid
|
|
212
|
+
if workdir is not None:
|
|
213
|
+
payload["workdir"] = workdir
|
|
214
|
+
if envs is not None:
|
|
215
|
+
payload["envs"] = envs
|
|
216
|
+
return payload
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _build_archive_sync_request(
|
|
220
|
+
files: dict[str, str | bytes] | bytes,
|
|
221
|
+
*,
|
|
222
|
+
dest: str = "/workspace",
|
|
223
|
+
uid: int | None = None,
|
|
224
|
+
gid: int | None = None,
|
|
225
|
+
remove_paths: Sequence[str] | None = None,
|
|
226
|
+
) -> tuple[bytes, dict[str, str]]:
|
|
227
|
+
if isinstance(files, (bytes, bytearray)):
|
|
228
|
+
tar_bytes = bytes(files)
|
|
229
|
+
else:
|
|
230
|
+
tar_bytes = _build_tar_gz_bytes(
|
|
231
|
+
files,
|
|
232
|
+
uid=uid if uid is not None else 1000,
|
|
233
|
+
gid=gid if gid is not None else 1000,
|
|
234
|
+
)
|
|
235
|
+
params: dict[str, str] = {"dest": dest}
|
|
236
|
+
if uid is not None:
|
|
237
|
+
params["uid"] = str(uid)
|
|
238
|
+
if gid is not None:
|
|
239
|
+
params["gid"] = str(gid)
|
|
240
|
+
if remove_paths:
|
|
241
|
+
joined = ",".join(p for p in remove_paths if p)
|
|
242
|
+
if joined:
|
|
243
|
+
params["remove_paths"] = joined
|
|
244
|
+
return tar_bytes, params
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _build_desktop_start_payload(
|
|
248
|
+
*,
|
|
249
|
+
resolution: str | None = None,
|
|
250
|
+
initial_url: str | None = None,
|
|
251
|
+
expires_in_seconds: int = 3600,
|
|
252
|
+
) -> dict[str, Any]:
|
|
253
|
+
payload: dict[str, Any] = {"expiresInSeconds": expires_in_seconds}
|
|
254
|
+
if resolution is not None:
|
|
255
|
+
payload["resolution"] = resolution
|
|
256
|
+
if initial_url is not None:
|
|
257
|
+
payload["initialUrl"] = initial_url
|
|
258
|
+
return payload
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _build_template_register_payload(
|
|
262
|
+
*,
|
|
263
|
+
name: str | None,
|
|
264
|
+
alias: str | None,
|
|
265
|
+
tags: list[str] | None,
|
|
266
|
+
cpu_count: int | None,
|
|
267
|
+
memory_mb: int | None,
|
|
268
|
+
min_free_disk_mb: int | None,
|
|
269
|
+
) -> dict[str, Any]:
|
|
270
|
+
payload: dict[str, Any] = {}
|
|
271
|
+
if name is not None:
|
|
272
|
+
payload["name"] = name
|
|
273
|
+
if alias is not None:
|
|
274
|
+
payload["alias"] = alias
|
|
275
|
+
if tags is not None:
|
|
276
|
+
payload["tags"] = tags
|
|
277
|
+
if cpu_count is not None:
|
|
278
|
+
payload["cpuCount"] = cpu_count
|
|
279
|
+
if memory_mb is not None:
|
|
280
|
+
payload["memoryMB"] = memory_mb
|
|
281
|
+
if min_free_disk_mb is not None:
|
|
282
|
+
payload["minFreeDiskMb"] = min_free_disk_mb
|
|
283
|
+
return payload
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _build_template_result(created: dict[str, Any]) -> dict[str, Any]:
|
|
287
|
+
return {
|
|
288
|
+
"templateID": str(created.get("templateID", "")),
|
|
289
|
+
"buildID": str(created.get("buildID", "")),
|
|
290
|
+
"aliases": created.get("aliases", []),
|
|
291
|
+
"names": created.get("names", []),
|
|
292
|
+
"tags": created.get("tags", []),
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _format_build_error(build_id: str, reason: Any) -> str:
|
|
297
|
+
if isinstance(reason, dict):
|
|
298
|
+
msg = reason.get("message") or str(reason)
|
|
299
|
+
step = reason.get("step")
|
|
300
|
+
if step:
|
|
301
|
+
msg = f"{msg} (step: {step})"
|
|
302
|
+
elif reason:
|
|
303
|
+
msg = str(reason)
|
|
304
|
+
else:
|
|
305
|
+
msg = "unknown build error"
|
|
306
|
+
return f"Template build {build_id} failed: {msg}"
|