arker 0.1.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.
- arker-0.1.0/.gitignore +19 -0
- arker-0.1.0/PKG-INFO +152 -0
- arker-0.1.0/README.md +127 -0
- arker-0.1.0/pyproject.toml +44 -0
- arker-0.1.0/src/arker/__init__.py +7 -0
- arker-0.1.0/src/arker/computer.py +319 -0
- arker-0.1.0/tests/demo.py +209 -0
arker-0.1.0/.gitignore
ADDED
arker-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Arker virtual computer platform.
|
|
5
|
+
Project-URL: Homepage, https://arker.ai
|
|
6
|
+
Project-URL: Documentation, https://arker.ai/docs
|
|
7
|
+
Project-URL: Source, https://github.com/ArkerHQ/arker-python-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/ArkerHQ/arker-python-sdk/issues
|
|
9
|
+
Author-email: Arker <support@arker.ai>
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: agent,arker,code-execution,sandbox,vm
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Arker — Python SDK
|
|
27
|
+
|
|
28
|
+
Single-file Python client for the [Arker](https://arker.ai) virtual computer
|
|
29
|
+
platform. Spawn isolated Linux sandboxes, run shell / Python / Node code in
|
|
30
|
+
them, read and write files. Zero runtime dependencies (stdlib `urllib`).
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install arker
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or, while in alpha:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install git+https://github.com/ArkerHQ/arker-python-sdk@v0.1.0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from arker import Arker, ArkerError
|
|
48
|
+
|
|
49
|
+
arker = Arker(api_key="ark_live_...")
|
|
50
|
+
vm = arker.vm("arkuntu").fork(name="hello") # fresh VM from base image
|
|
51
|
+
result = vm.run("python3 -c 'print(2+2)'")
|
|
52
|
+
print(result.stdout.decode()) # → "4\n"
|
|
53
|
+
|
|
54
|
+
vm.sync.write_file("/home/user/data.csv", b"a,b\n1,2\n")
|
|
55
|
+
data = vm.sync.read_file("/home/user/data.csv") # → b"a,b\n1,2\n"
|
|
56
|
+
|
|
57
|
+
child = vm.fork(name="branch") # constant-time copy-on-write
|
|
58
|
+
child.delete()
|
|
59
|
+
vm.delete()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
List your VMs:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
page = arker.list(limit=10, sort="-created_at")
|
|
66
|
+
print(f"{page.total} total")
|
|
67
|
+
for summary in page: # iterable; also page.items
|
|
68
|
+
print(summary.vm_id, summary.name, summary.region, summary.created_at)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
Arker(api_key, base_url=None)
|
|
75
|
+
.vm(vm_id) -> Computer # open handle (no network call)
|
|
76
|
+
.list(*, limit=25, offset=0, q=None, sort=None) -> VmList
|
|
77
|
+
|
|
78
|
+
Computer
|
|
79
|
+
.id, .delete()
|
|
80
|
+
.fork(*, name=, is_public=, region=) -> Computer
|
|
81
|
+
.run(command, *, session_id=, timeout=) -> RunResult
|
|
82
|
+
.sync.read_file(path) -> bytes
|
|
83
|
+
.sync.write_file(path, data: bytes | str)
|
|
84
|
+
|
|
85
|
+
RunResult: stdout, stderr (bytes), exit_code, duration_ms, session_id, cwd
|
|
86
|
+
VmSummary: vm_id, name, base_image, region, created_at (ISO 8601)
|
|
87
|
+
VmList: items (list[VmSummary]), total (int); iterable, len()-able
|
|
88
|
+
|
|
89
|
+
ArkerError(code, message, status) # one exception type for everything
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Routing
|
|
93
|
+
|
|
94
|
+
`fork`, `run`, `sync`, and `delete` use the regional endpoint set on the
|
|
95
|
+
client (default `https://aws-us-west-2.burst.arker.ai`).
|
|
96
|
+
|
|
97
|
+
`list` always goes through `https://arker.ai` regardless of `base_url`,
|
|
98
|
+
because list data is served from a global host rather than a regional
|
|
99
|
+
one.
|
|
100
|
+
|
|
101
|
+
Public base-image names like `"arkuntu"` resolve to a ULID **client-side**
|
|
102
|
+
(see `SOURCE_ALIASES` in `computer.py`), so `arker.vm("arkuntu").fork()`
|
|
103
|
+
works on the default endpoint with no extra round-trip. Override
|
|
104
|
+
`base_url` or set `ARKER_BASE_URL` to point at a different region or a
|
|
105
|
+
self-hosted deployment.
|
|
106
|
+
|
|
107
|
+
### Errors
|
|
108
|
+
|
|
109
|
+
Every server-side error becomes an `ArkerError`:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
try:
|
|
113
|
+
vm.sync.read_file("/home/user/missing")
|
|
114
|
+
except ArkerError as err:
|
|
115
|
+
print(err.code) # "not_found"
|
|
116
|
+
print(err.message) # "file not found: /home/user/missing"
|
|
117
|
+
print(err.status) # 404
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`code` is a stable enum: `bad_request`, `unauthorized`, `payment_required`,
|
|
121
|
+
`forbidden`, `not_found`, `conflict`, `payload_too_large`, `internal`,
|
|
122
|
+
`not_implemented`, `vm_busy`, `unsupported_*`, `command_not_found`.
|
|
123
|
+
|
|
124
|
+
### What the SDK does for you
|
|
125
|
+
|
|
126
|
+
Hidden behind these six methods:
|
|
127
|
+
|
|
128
|
+
- **Write strategy**: files up to 100 MB. Small payloads go in one call;
|
|
129
|
+
larger ones use a direct upload path so the bytes don't traverse the
|
|
130
|
+
API layer. `write_file` returns once the bytes are durably stored.
|
|
131
|
+
- **Read coalescing**: `read_file` always returns raw `bytes`, regardless
|
|
132
|
+
of whether the server inlined the content or returned a signed URL.
|
|
133
|
+
- **Idempotent retry**: transient errors are retried with exponential
|
|
134
|
+
backoff. Writes are server-side idempotent on `upload_id`, so retries
|
|
135
|
+
never produce duplicates.
|
|
136
|
+
- **Path validation**: only `/home/user/...` paths accepted; `..` rejected.
|
|
137
|
+
|
|
138
|
+
## Demo / smoke test
|
|
139
|
+
|
|
140
|
+
Run the full surface against a live deployment:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
ARKER_API_KEY=ark_live_... python tests/demo.py
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It exercises every method (`list`, `vm`, `fork`, `run`, `sync.write_file`,
|
|
147
|
+
`sync.read_file`, error path, child fork, `delete`) and prints what each
|
|
148
|
+
call hits on the wire — useful as living documentation.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
Apache-2.0.
|
arker-0.1.0/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Arker — Python SDK
|
|
2
|
+
|
|
3
|
+
Single-file Python client for the [Arker](https://arker.ai) virtual computer
|
|
4
|
+
platform. Spawn isolated Linux sandboxes, run shell / Python / Node code in
|
|
5
|
+
them, read and write files. Zero runtime dependencies (stdlib `urllib`).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install arker
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or, while in alpha:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install git+https://github.com/ArkerHQ/arker-python-sdk@v0.1.0
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from arker import Arker, ArkerError
|
|
23
|
+
|
|
24
|
+
arker = Arker(api_key="ark_live_...")
|
|
25
|
+
vm = arker.vm("arkuntu").fork(name="hello") # fresh VM from base image
|
|
26
|
+
result = vm.run("python3 -c 'print(2+2)'")
|
|
27
|
+
print(result.stdout.decode()) # → "4\n"
|
|
28
|
+
|
|
29
|
+
vm.sync.write_file("/home/user/data.csv", b"a,b\n1,2\n")
|
|
30
|
+
data = vm.sync.read_file("/home/user/data.csv") # → b"a,b\n1,2\n"
|
|
31
|
+
|
|
32
|
+
child = vm.fork(name="branch") # constant-time copy-on-write
|
|
33
|
+
child.delete()
|
|
34
|
+
vm.delete()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
List your VMs:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
page = arker.list(limit=10, sort="-created_at")
|
|
41
|
+
print(f"{page.total} total")
|
|
42
|
+
for summary in page: # iterable; also page.items
|
|
43
|
+
print(summary.vm_id, summary.name, summary.region, summary.created_at)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
Arker(api_key, base_url=None)
|
|
50
|
+
.vm(vm_id) -> Computer # open handle (no network call)
|
|
51
|
+
.list(*, limit=25, offset=0, q=None, sort=None) -> VmList
|
|
52
|
+
|
|
53
|
+
Computer
|
|
54
|
+
.id, .delete()
|
|
55
|
+
.fork(*, name=, is_public=, region=) -> Computer
|
|
56
|
+
.run(command, *, session_id=, timeout=) -> RunResult
|
|
57
|
+
.sync.read_file(path) -> bytes
|
|
58
|
+
.sync.write_file(path, data: bytes | str)
|
|
59
|
+
|
|
60
|
+
RunResult: stdout, stderr (bytes), exit_code, duration_ms, session_id, cwd
|
|
61
|
+
VmSummary: vm_id, name, base_image, region, created_at (ISO 8601)
|
|
62
|
+
VmList: items (list[VmSummary]), total (int); iterable, len()-able
|
|
63
|
+
|
|
64
|
+
ArkerError(code, message, status) # one exception type for everything
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Routing
|
|
68
|
+
|
|
69
|
+
`fork`, `run`, `sync`, and `delete` use the regional endpoint set on the
|
|
70
|
+
client (default `https://aws-us-west-2.burst.arker.ai`).
|
|
71
|
+
|
|
72
|
+
`list` always goes through `https://arker.ai` regardless of `base_url`,
|
|
73
|
+
because list data is served from a global host rather than a regional
|
|
74
|
+
one.
|
|
75
|
+
|
|
76
|
+
Public base-image names like `"arkuntu"` resolve to a ULID **client-side**
|
|
77
|
+
(see `SOURCE_ALIASES` in `computer.py`), so `arker.vm("arkuntu").fork()`
|
|
78
|
+
works on the default endpoint with no extra round-trip. Override
|
|
79
|
+
`base_url` or set `ARKER_BASE_URL` to point at a different region or a
|
|
80
|
+
self-hosted deployment.
|
|
81
|
+
|
|
82
|
+
### Errors
|
|
83
|
+
|
|
84
|
+
Every server-side error becomes an `ArkerError`:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
try:
|
|
88
|
+
vm.sync.read_file("/home/user/missing")
|
|
89
|
+
except ArkerError as err:
|
|
90
|
+
print(err.code) # "not_found"
|
|
91
|
+
print(err.message) # "file not found: /home/user/missing"
|
|
92
|
+
print(err.status) # 404
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`code` is a stable enum: `bad_request`, `unauthorized`, `payment_required`,
|
|
96
|
+
`forbidden`, `not_found`, `conflict`, `payload_too_large`, `internal`,
|
|
97
|
+
`not_implemented`, `vm_busy`, `unsupported_*`, `command_not_found`.
|
|
98
|
+
|
|
99
|
+
### What the SDK does for you
|
|
100
|
+
|
|
101
|
+
Hidden behind these six methods:
|
|
102
|
+
|
|
103
|
+
- **Write strategy**: files up to 100 MB. Small payloads go in one call;
|
|
104
|
+
larger ones use a direct upload path so the bytes don't traverse the
|
|
105
|
+
API layer. `write_file` returns once the bytes are durably stored.
|
|
106
|
+
- **Read coalescing**: `read_file` always returns raw `bytes`, regardless
|
|
107
|
+
of whether the server inlined the content or returned a signed URL.
|
|
108
|
+
- **Idempotent retry**: transient errors are retried with exponential
|
|
109
|
+
backoff. Writes are server-side idempotent on `upload_id`, so retries
|
|
110
|
+
never produce duplicates.
|
|
111
|
+
- **Path validation**: only `/home/user/...` paths accepted; `..` rejected.
|
|
112
|
+
|
|
113
|
+
## Demo / smoke test
|
|
114
|
+
|
|
115
|
+
Run the full surface against a live deployment:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
ARKER_API_KEY=ark_live_... python tests/demo.py
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
It exercises every method (`list`, `vm`, `fork`, `run`, `sync.write_file`,
|
|
122
|
+
`sync.read_file`, error path, child fork, `delete`) and prints what each
|
|
123
|
+
call hits on the wire — useful as living documentation.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
Apache-2.0.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "arker"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the Arker virtual computer platform."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "Arker", email = "support@arker.ai" }]
|
|
13
|
+
keywords = ["arker", "vm", "sandbox", "agent", "code-execution"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
]
|
|
25
|
+
# Zero runtime dependencies — stdlib `urllib` only.
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
test = ["pytest>=8.0"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://arker.ai"
|
|
33
|
+
Documentation = "https://arker.ai/docs"
|
|
34
|
+
Source = "https://github.com/ArkerHQ/arker-python-sdk"
|
|
35
|
+
Issues = "https://github.com/ArkerHQ/arker-python-sdk/issues"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/arker"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
# `test_e2e.py` and `test_presigned.py` hit a live deployment; skip
|
|
43
|
+
# unless ARKER_API_KEY is set in the environment.
|
|
44
|
+
addopts = "-q"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Arker — single-file Python client for the Arker virtual computer
|
|
2
|
+
platform. See README.md for the quickstart, `arker/computer.py` for
|
|
3
|
+
the implementation, and `docs/sync-api.md` for wire-level details."""
|
|
4
|
+
from .computer import Arker, Computer, Sync, RunResult, VmSummary, VmList, ArkerError
|
|
5
|
+
|
|
6
|
+
__all__ = ["Arker", "Computer", "Sync", "RunResult", "VmSummary", "VmList", "ArkerError"]
|
|
7
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Arker SDK — single-file Python client.
|
|
2
|
+
|
|
3
|
+
Quickstart:
|
|
4
|
+
|
|
5
|
+
from arker import Arker
|
|
6
|
+
arker = Arker(api_key="ark_live_...")
|
|
7
|
+
vm = arker.vm("arkuntu").fork(name="hello") # fresh VM from base image
|
|
8
|
+
result = vm.run("echo hi") # result.stdout: bytes, result.exit_code: int
|
|
9
|
+
|
|
10
|
+
vm.sync.write_file("/home/user/data.bin", b"...")
|
|
11
|
+
blob = vm.sync.read_file("/home/user/data.bin") # → bytes
|
|
12
|
+
|
|
13
|
+
child = vm.fork(name="branch") # branch off this VM
|
|
14
|
+
child.delete(); vm.delete()
|
|
15
|
+
|
|
16
|
+
# List your VMs (paginated):
|
|
17
|
+
page = arker.list(limit=10)
|
|
18
|
+
for summary in page.items:
|
|
19
|
+
print(summary.vm_id, summary.name, summary.created_at)
|
|
20
|
+
|
|
21
|
+
# Re-open an existing VM by ID (no network call):
|
|
22
|
+
same_vm = arker.vm("01ABC...XYZ_d8c0")
|
|
23
|
+
|
|
24
|
+
Errors raise `ArkerError(code, message, status)`.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
import base64, dataclasses, json, os, secrets, time, urllib.error, urllib.parse, urllib.request
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
DEFAULT_BASE_URL = "https://aws-us-west-2.burst.arker.ai"
|
|
31
|
+
LIST_BASE_URL = "https://arker.ai" # `list` is served from a different host than the rest;
|
|
32
|
+
# used regardless of the client's base_url.
|
|
33
|
+
SOURCE_ALIASES = {"arkuntu": "01KQBYKEV5WJ7YB010603T1DCT_d8c0"} # public base images
|
|
34
|
+
CHUNK_SIZE = 4 * 1024 * 1024 # files above this go through a direct upload
|
|
35
|
+
PRESIGN_EXPIRES = 900 # signed-URL lifetime (s)
|
|
36
|
+
RETRYABLE_HTTP = {429, 502, 503, 504}
|
|
37
|
+
# Substring matches in `error.message` that mark a transient failure
|
|
38
|
+
# the server expects clients to retry. Matched verbatim against the
|
|
39
|
+
# message returned in the error envelope.
|
|
40
|
+
_TRANSIENT_HINTS = ("503", "Service Unavailable", "throttle", "SlowDown", "ThrottlingException")
|
|
41
|
+
MAX_ATTEMPTS = 4
|
|
42
|
+
BACKOFF_S = 0.2
|
|
43
|
+
ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ArkerError(Exception):
|
|
47
|
+
def __init__(self, code: str, message: str, status: int) -> None:
|
|
48
|
+
super().__init__(f"{code}: {message}")
|
|
49
|
+
self.code, self.message, self.status = code, message, status
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclasses.dataclass
|
|
53
|
+
class RunResult:
|
|
54
|
+
stdout: bytes; stderr: bytes; exit_code: int
|
|
55
|
+
duration_ms: float; session_id: str; cwd: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclasses.dataclass
|
|
59
|
+
class VmSummary:
|
|
60
|
+
"""One row from `Arker.list()`. `created_at` is ISO 8601 UTC."""
|
|
61
|
+
vm_id: str
|
|
62
|
+
name: str | None
|
|
63
|
+
base_image: str
|
|
64
|
+
region: str
|
|
65
|
+
created_at: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclasses.dataclass
|
|
69
|
+
class VmList:
|
|
70
|
+
items: list[VmSummary]
|
|
71
|
+
total: int # total matching the query, ignoring limit/offset
|
|
72
|
+
def __iter__(self): return iter(self.items)
|
|
73
|
+
def __len__(self): return len(self.items)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ulid() -> str:
|
|
77
|
+
"""26-char Crockford-base32 ULID; not strictly monotonic, but unique
|
|
78
|
+
enough — the server's `If-None-Match: *` catches any duplicates."""
|
|
79
|
+
raw = ((int(time.time() * 1000) & ((1 << 48) - 1)) << 80) | secrets.randbits(80)
|
|
80
|
+
out = []
|
|
81
|
+
for _ in range(26):
|
|
82
|
+
out.append(ULID_ALPHABET[raw & 31]); raw >>= 5
|
|
83
|
+
return "".join(reversed(out))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _looks_like_vm_id(s: str) -> bool:
|
|
87
|
+
"""ULID-shape check: 26 Crockford chars, optionally `_<region>` suffix
|
|
88
|
+
of 1+ alphanumeric chars (e.g. `_uswe`, `_d8c0`). Used by `fork()`
|
|
89
|
+
to dispatch by-id vs by-ref."""
|
|
90
|
+
if "_" in s:
|
|
91
|
+
head, _, tail = s.partition("_")
|
|
92
|
+
if not tail or not all(c.isalnum() for c in tail):
|
|
93
|
+
return False
|
|
94
|
+
else:
|
|
95
|
+
head = s
|
|
96
|
+
if len(head) != 26:
|
|
97
|
+
return False
|
|
98
|
+
return all(c in ULID_ALPHABET for c in head.upper())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _decode_stream(text: Any, encoding: Any) -> bytes:
|
|
102
|
+
s = text or ""
|
|
103
|
+
if encoding == "base64":
|
|
104
|
+
try: return base64.b64decode(s)
|
|
105
|
+
except Exception: pass
|
|
106
|
+
return (s if isinstance(s, str) else "").encode("utf-8", "replace")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_transient(err: dict | None) -> bool:
|
|
110
|
+
if not err or err.get("code") != "internal":
|
|
111
|
+
return False
|
|
112
|
+
return any(h in (err.get("message") or "") for h in _TRANSIENT_HINTS)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _http(method: str, url: str, headers: dict, body: bytes | None) -> tuple[int, bytes]:
|
|
116
|
+
"""Single-shot urllib request. Retry policy lives in `Arker._request`."""
|
|
117
|
+
req = urllib.request.Request(url, method=method, headers=headers, data=body)
|
|
118
|
+
try:
|
|
119
|
+
resp = urllib.request.urlopen(req, timeout=120)
|
|
120
|
+
return resp.status, resp.read()
|
|
121
|
+
except urllib.error.HTTPError as e:
|
|
122
|
+
return e.code, e.read()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Arker:
|
|
126
|
+
"""Top-level client. Default `base_url` points at the production
|
|
127
|
+
regional endpoint; override per-client or via the `ARKER_BASE_URL`
|
|
128
|
+
env var to use a different region or a self-hosted deployment."""
|
|
129
|
+
def __init__(self, api_key: str, base_url: str | None = None) -> None:
|
|
130
|
+
if not api_key: raise ValueError("api_key is required")
|
|
131
|
+
self._api_key = api_key
|
|
132
|
+
self._base_url = (base_url or os.environ.get("ARKER_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
133
|
+
|
|
134
|
+
def _request(self, method: str, path: str, body: dict | None = None,
|
|
135
|
+
*, base_url: str | None = None) -> dict:
|
|
136
|
+
"""One retry budget covering both transient HTTP statuses and
|
|
137
|
+
envelope-level transient errors. `base_url=` overrides the client
|
|
138
|
+
default for routes that live on a different host (e.g. `list`)."""
|
|
139
|
+
headers = {"authorization": f"Bearer {self._api_key}", "content-type": "application/json"}
|
|
140
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
141
|
+
url = (base_url.rstrip("/") if base_url else self._base_url) + path
|
|
142
|
+
last_status, last_err, last_payload = 0, None, None
|
|
143
|
+
for attempt in range(MAX_ATTEMPTS):
|
|
144
|
+
status, raw = _http(method, url, headers, data)
|
|
145
|
+
try: payload = json.loads(raw) if raw else {}
|
|
146
|
+
except json.JSONDecodeError: payload = None
|
|
147
|
+
last_status, last_payload = status, payload
|
|
148
|
+
envelope_err = (payload.get("error") if isinstance(payload, dict) and payload.get("ok") is False else None)
|
|
149
|
+
last_err = envelope_err
|
|
150
|
+
if status in RETRYABLE_HTTP or _is_transient(envelope_err):
|
|
151
|
+
if attempt == MAX_ATTEMPTS - 1: break
|
|
152
|
+
time.sleep(BACKOFF_S * (2 ** attempt) + secrets.randbelow(50) / 1000.0)
|
|
153
|
+
continue
|
|
154
|
+
if envelope_err is not None:
|
|
155
|
+
raise ArkerError(envelope_err.get("code", "internal"), envelope_err.get("message", ""), status)
|
|
156
|
+
if status >= 400:
|
|
157
|
+
raise ArkerError("internal", str(payload)[:200], status)
|
|
158
|
+
return payload # type: ignore[return-value]
|
|
159
|
+
if last_err is not None:
|
|
160
|
+
raise ArkerError(last_err.get("code", "internal"), last_err.get("message", ""), last_status)
|
|
161
|
+
raise ArkerError("internal", str(last_payload)[:200], last_status)
|
|
162
|
+
|
|
163
|
+
def vm(self, vm_id: str) -> "Computer":
|
|
164
|
+
"""Open a handle to a VM by ULID *or* by template name (e.g.
|
|
165
|
+
`"arkuntu"`). Names resolve on `.fork()` only — you can't
|
|
166
|
+
`.run()` against a name handle. No network call here."""
|
|
167
|
+
return Computer(self, vm_id)
|
|
168
|
+
|
|
169
|
+
def list(self, *, limit: int = 25, offset: int = 0,
|
|
170
|
+
q: str | None = None, sort: str | None = None) -> VmList:
|
|
171
|
+
"""List VMs in the caller's organization. Always hits
|
|
172
|
+
`https://arker.ai` regardless of the client's `base_url` — list
|
|
173
|
+
data is served from a global host rather than the regional one.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
limit: 1–100, default 25.
|
|
177
|
+
offset: ≥0, default 0.
|
|
178
|
+
q: substring filter on VM name.
|
|
179
|
+
sort: `created_at` | `-created_at` | `region` | `-region`.
|
|
180
|
+
Default `-created_at` (newest first).
|
|
181
|
+
"""
|
|
182
|
+
params: dict[str, str] = {}
|
|
183
|
+
if limit != 25: params["limit"] = str(limit)
|
|
184
|
+
if offset != 0: params["offset"] = str(offset)
|
|
185
|
+
if q is not None: params["q"] = q
|
|
186
|
+
if sort is not None: params["sort"] = sort
|
|
187
|
+
path = "/api/v1/vms/list"
|
|
188
|
+
if params:
|
|
189
|
+
path += "?" + urllib.parse.urlencode(params)
|
|
190
|
+
r = self._request("GET", path, base_url=LIST_BASE_URL)
|
|
191
|
+
items = [VmSummary(
|
|
192
|
+
vm_id = i["vm_id"],
|
|
193
|
+
name = i.get("name"),
|
|
194
|
+
base_image = i["base_image"],
|
|
195
|
+
region = i["region"],
|
|
196
|
+
created_at = i["created_at"],
|
|
197
|
+
) for i in r.get("items", [])]
|
|
198
|
+
return VmList(items=items, total=int(r.get("total", 0)))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Computer:
|
|
202
|
+
"""A handle on one VM. Methods: `.delete()`, `.fork(...)`, `.run(...)`,
|
|
203
|
+
`.sync.read_file(path)`, `.sync.write_file(path, data)`."""
|
|
204
|
+
def __init__(self, client: Arker, vm_id: str) -> None:
|
|
205
|
+
self._client, self.id = client, vm_id
|
|
206
|
+
self._default_session: str | None = None
|
|
207
|
+
self.sync = Sync(self)
|
|
208
|
+
|
|
209
|
+
def delete(self) -> None:
|
|
210
|
+
self._client._request("DELETE", f"/api/v1/vms/{self.id}")
|
|
211
|
+
|
|
212
|
+
def fork(self, *, name: str | None = None, is_public: bool = False,
|
|
213
|
+
region: str | None = None) -> "Computer":
|
|
214
|
+
"""Branch off this VM. Aliases like `"arkuntu"` resolve to a
|
|
215
|
+
ULID client-side via `SOURCE_ALIASES`; the request then uses
|
|
216
|
+
the by-id endpoint so it works on the default `base_url`."""
|
|
217
|
+
body: dict[str, Any] = {"is_public": is_public}
|
|
218
|
+
if name is not None: body["name"] = name
|
|
219
|
+
if region is not None: body["region"] = region
|
|
220
|
+
resolved = SOURCE_ALIASES.get(self.id, self.id)
|
|
221
|
+
if _looks_like_vm_id(resolved):
|
|
222
|
+
r = self._client._request("POST", f"/api/v1/vms/{resolved}/fork", body)
|
|
223
|
+
else:
|
|
224
|
+
# Unknown name; let the global host resolve it.
|
|
225
|
+
body["from"] = self.id
|
|
226
|
+
r = self._client._request("POST", "/api/v1/vms/fork", body, base_url=LIST_BASE_URL)
|
|
227
|
+
if not r.get("vm_id"):
|
|
228
|
+
raise ArkerError("internal", "fork response missing vm_id", 200)
|
|
229
|
+
return Computer(self._client, r["vm_id"])
|
|
230
|
+
|
|
231
|
+
def run(self, command: str, *, session_id: str | int | None = None,
|
|
232
|
+
timeout: int | None = None) -> RunResult:
|
|
233
|
+
body: dict[str, Any] = {"command": command,
|
|
234
|
+
"session_id": session_id if session_id is not None else (self._default_session or 0)}
|
|
235
|
+
if timeout is not None: body["timeout"] = timeout
|
|
236
|
+
r = self._client._request("POST", f"/api/v1/vms/{self.id}/run", body)
|
|
237
|
+
return RunResult(
|
|
238
|
+
stdout=_decode_stream(r.get("stdout"), r.get("stdout_encoding")),
|
|
239
|
+
stderr=_decode_stream(r.get("stderr"), r.get("stderr_encoding")),
|
|
240
|
+
exit_code=int(r.get("exit_code", 0)),
|
|
241
|
+
duration_ms=float(r.get("duration_ms", 0.0)),
|
|
242
|
+
session_id=str(r.get("session_id", "")),
|
|
243
|
+
cwd=str(r.get("cwd", "")),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class Sync:
|
|
248
|
+
"""File I/O for one VM. Routes through `/api/v1/vms/{id}/sync`. Hides
|
|
249
|
+
chunk-vs-presigned write strategy and inline-vs-presigned reads."""
|
|
250
|
+
def __init__(self, vm: Computer) -> None:
|
|
251
|
+
self._vm, self._client = vm, vm._client
|
|
252
|
+
|
|
253
|
+
def _path(self) -> str: return f"/api/v1/vms/{self._vm.id}/sync"
|
|
254
|
+
|
|
255
|
+
def read_file(self, path: str) -> bytes:
|
|
256
|
+
r = self._client._request("POST", self._path(), {"op": "read", "path": path})
|
|
257
|
+
if "content" in r:
|
|
258
|
+
c = r["content"]
|
|
259
|
+
return base64.b64decode(c) if r.get("encoding") == "base64" else c.encode("utf-8")
|
|
260
|
+
url = r.get("presigned_url")
|
|
261
|
+
if not url: raise ArkerError("internal", "read response missing content/presigned_url", 200)
|
|
262
|
+
with urllib.request.urlopen(url, timeout=300) as resp:
|
|
263
|
+
return resp.read()
|
|
264
|
+
|
|
265
|
+
def write_file(self, path: str, data: bytes | str) -> None:
|
|
266
|
+
if isinstance(data, str): data = data.encode("utf-8")
|
|
267
|
+
if not data: raise ArkerError("bad_request", "write_file: empty data", 400)
|
|
268
|
+
if len(data) <= CHUNK_SIZE:
|
|
269
|
+
self._fast_path(path, data)
|
|
270
|
+
else:
|
|
271
|
+
self._presigned(path, data)
|
|
272
|
+
|
|
273
|
+
def _fast_path(self, path: str, data: bytes) -> None:
|
|
274
|
+
"""Single-chunk write for payloads ≤ CHUNK_SIZE: one round-trip,
|
|
275
|
+
bytes carried inline."""
|
|
276
|
+
size = len(data)
|
|
277
|
+
entry = {"path": path, "size": size, "upload_id": _ulid(),
|
|
278
|
+
"start": 0, "end": size,
|
|
279
|
+
"content": base64.b64encode(data).decode("ascii")}
|
|
280
|
+
result = self._send_one(entry)
|
|
281
|
+
if not (result.get("complete") and result.get("written")):
|
|
282
|
+
raise ArkerError("internal", "fast-path write returned without complete+written", 200)
|
|
283
|
+
|
|
284
|
+
def _presigned(self, path: str, data: bytes) -> None:
|
|
285
|
+
"""> CHUNK_SIZE: request a signed upload URL, PUT bytes directly,
|
|
286
|
+
then commit. Bytes never pass through the API layer."""
|
|
287
|
+
size = len(data)
|
|
288
|
+
# Step 1 — request the signed URL.
|
|
289
|
+
e1 = self._send_one({"path": path, "size": size, "presigned": True})
|
|
290
|
+
url, upload_id = e1["presigned_url"], e1["upload_id"]
|
|
291
|
+
# Step 2 — direct PUT to the upload URL, retrying transient HTTP statuses.
|
|
292
|
+
for attempt in range(MAX_ATTEMPTS):
|
|
293
|
+
try:
|
|
294
|
+
req = urllib.request.Request(url, method="PUT", data=data)
|
|
295
|
+
with urllib.request.urlopen(req, timeout=600) as resp:
|
|
296
|
+
if resp.status >= 400:
|
|
297
|
+
raise urllib.error.HTTPError(url, resp.status, "fail", {}, None)
|
|
298
|
+
break
|
|
299
|
+
except urllib.error.HTTPError as e:
|
|
300
|
+
if e.code not in RETRYABLE_HTTP or attempt == MAX_ATTEMPTS - 1:
|
|
301
|
+
raise ArkerError("internal", f"upload PUT failed: {e.code}", e.code)
|
|
302
|
+
time.sleep(BACKOFF_S * (2 ** attempt))
|
|
303
|
+
# Step 3 — commit.
|
|
304
|
+
self._send_one({"path": path, "size": size, "upload_id": upload_id})
|
|
305
|
+
|
|
306
|
+
def _send_one(self, entry: dict) -> dict:
|
|
307
|
+
"""POST a single-entry write request, retrying transient
|
|
308
|
+
envelope-level errors (HTTP 200 with `error.code:"internal"` and
|
|
309
|
+
a throttling-style hint in the message)."""
|
|
310
|
+
last_err = None
|
|
311
|
+
for attempt in range(MAX_ATTEMPTS):
|
|
312
|
+
r = self._client._request("POST", self._path(), {"op": "write", "writes": [entry]})
|
|
313
|
+
result = (r.get("results") or [{}])[0]
|
|
314
|
+
err = result.get("error")
|
|
315
|
+
if not err: return result
|
|
316
|
+
last_err = err
|
|
317
|
+
if not _is_transient(err) or attempt == MAX_ATTEMPTS - 1: break
|
|
318
|
+
time.sleep(BACKOFF_S * (2 ** attempt) + secrets.randbelow(50) / 1000.0)
|
|
319
|
+
raise ArkerError(last_err.get("code", "internal"), last_err.get("message", ""), 200)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Live SDK demo: exercises every public method against the production
|
|
2
|
+
deployment, printing what each call sends on the wire and the result.
|
|
3
|
+
Doubles as smoke test and as documentation — if you wonder "what does
|
|
4
|
+
`a.list(q='hello')` actually do?", look at the printed line and the
|
|
5
|
+
returned data.
|
|
6
|
+
|
|
7
|
+
Run:
|
|
8
|
+
ARKER_API_KEY=ark_live_... python sdk/python/tests/demo.py
|
|
9
|
+
|
|
10
|
+
Exits 0 on full pass, 1 on any failure.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import urllib.request
|
|
20
|
+
|
|
21
|
+
import arker
|
|
22
|
+
from arker import Arker, ArkerError, Computer, RunResult, VmList, VmSummary
|
|
23
|
+
|
|
24
|
+
API_KEY = os.environ.get("ARKER_API_KEY") or os.environ.get("AUTH_KEY")
|
|
25
|
+
if not API_KEY:
|
|
26
|
+
print("ARKER_API_KEY is required", file=sys.stderr)
|
|
27
|
+
sys.exit(2)
|
|
28
|
+
|
|
29
|
+
# ── Wire-level request tracing ─────────────────────────────────────────
|
|
30
|
+
# Wrap urlopen so every HTTP call the SDK makes is printed verbatim.
|
|
31
|
+
# This is what makes the demo double as documentation.
|
|
32
|
+
_orig_urlopen = urllib.request.urlopen
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _trace_urlopen(req, *args, **kwargs):
|
|
36
|
+
if hasattr(req, "full_url"):
|
|
37
|
+
method = req.get_method()
|
|
38
|
+
url = req.full_url
|
|
39
|
+
body_preview = ""
|
|
40
|
+
if req.data:
|
|
41
|
+
n = len(req.data)
|
|
42
|
+
body_preview = f" ({n} byte body)" if n > 200 else f" body={req.data[:200]!r}"
|
|
43
|
+
print(f" → {method} {url}{body_preview}")
|
|
44
|
+
else:
|
|
45
|
+
print(f" → GET {req}")
|
|
46
|
+
return _orig_urlopen(req, *args, **kwargs)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
urllib.request.urlopen = _trace_urlopen # type: ignore[assignment]
|
|
50
|
+
|
|
51
|
+
# ── Test harness ───────────────────────────────────────────────────────
|
|
52
|
+
results: list[tuple[str, bool, str]] = []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def section(title: str) -> None:
|
|
56
|
+
print(f"\n━━━ {title} ━━━")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check(name: str, ok: bool, detail: str = "") -> None:
|
|
60
|
+
results.append((name, ok, detail))
|
|
61
|
+
icon = "✅" if ok else "❌"
|
|
62
|
+
suffix = f" [{detail}]" if detail else ""
|
|
63
|
+
print(f" {icon} {name}{suffix}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── 0. Construct client ────────────────────────────────────────────────
|
|
67
|
+
section("Arker(api_key=...)")
|
|
68
|
+
print(f" arker version: {arker.__version__}")
|
|
69
|
+
arker_client = Arker(api_key=API_KEY)
|
|
70
|
+
print(f" default base_url: {arker_client._base_url}")
|
|
71
|
+
check("client constructed", isinstance(arker_client, Arker))
|
|
72
|
+
|
|
73
|
+
# ── 1. list() — paginated VMs (always hits arker.ai) ───────────────────
|
|
74
|
+
section("arker_client.list(limit=5) — should hit https://arker.ai")
|
|
75
|
+
page_before = arker_client.list(limit=5)
|
|
76
|
+
check(
|
|
77
|
+
"returns VmList",
|
|
78
|
+
isinstance(page_before, VmList) and all(isinstance(item, VmSummary) for item in page_before),
|
|
79
|
+
f"total={page_before.total} items={len(page_before)}",
|
|
80
|
+
)
|
|
81
|
+
for summary in page_before.items[:3]:
|
|
82
|
+
print(f" · {summary.vm_id} name={summary.name!r} region={summary.region} created={summary.created_at}")
|
|
83
|
+
|
|
84
|
+
# ── 2. vm() — open handle, no network call ─────────────────────────────
|
|
85
|
+
section('arker_client.vm("arkuntu") — handle, no network call')
|
|
86
|
+
arkuntu = arker_client.vm("arkuntu")
|
|
87
|
+
check(
|
|
88
|
+
'arker_client.vm("arkuntu") returns Computer',
|
|
89
|
+
isinstance(arkuntu, Computer) and arkuntu.id == "arkuntu",
|
|
90
|
+
f"id={arkuntu.id!r}",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# ── 3. fork() — alias resolves client-side, hits regional endpoint by-id ─
|
|
94
|
+
section('arkuntu.fork(name="demo") — alias resolved client-side')
|
|
95
|
+
vm = arkuntu.fork(name="sdk-demo")
|
|
96
|
+
check(
|
|
97
|
+
"fork returns Computer with new ULID id",
|
|
98
|
+
isinstance(vm, Computer) and vm.id != "arkuntu" and len(vm.id) >= 26,
|
|
99
|
+
f"vm.id={vm.id}",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# ── 4. run(simple command) ─────────────────────────────────────────
|
|
104
|
+
section('vm.run("echo hello") — POST .../run')
|
|
105
|
+
run_result = vm.run("echo hello-from-sdk")
|
|
106
|
+
check(
|
|
107
|
+
"run returns RunResult",
|
|
108
|
+
isinstance(run_result, RunResult),
|
|
109
|
+
f"exit={run_result.exit_code} duration_ms={run_result.duration_ms:.0f}",
|
|
110
|
+
)
|
|
111
|
+
check("stdout matches", run_result.stdout == b"hello-from-sdk\n", f"stdout={run_result.stdout!r}")
|
|
112
|
+
|
|
113
|
+
# ── 5. write_file (small / fast path) ──────────────────────────────
|
|
114
|
+
section('vm.sync.write_file("/home/user/small.txt", b"...") — single call')
|
|
115
|
+
payload_small = b"hello-small-payload\n"
|
|
116
|
+
vm.sync.write_file("/home/user/small.txt", payload_small)
|
|
117
|
+
check("small write returned", True, f"{len(payload_small)} bytes")
|
|
118
|
+
|
|
119
|
+
# ── 6. read_file (small / inline) ──────────────────────────────────
|
|
120
|
+
section('vm.sync.read_file("/home/user/small.txt") — inline response')
|
|
121
|
+
back_small = vm.sync.read_file("/home/user/small.txt")
|
|
122
|
+
check("small round-trip", back_small == payload_small)
|
|
123
|
+
|
|
124
|
+
# ── 7. cat the file via run() — proves wire is consistent ──────────
|
|
125
|
+
section('vm.run("cat /home/user/small.txt") — same bytes via shell')
|
|
126
|
+
cat_result = vm.run("cat /home/user/small.txt")
|
|
127
|
+
check(
|
|
128
|
+
"shell sees the SDK-written file",
|
|
129
|
+
cat_result.exit_code == 0 and cat_result.stdout == payload_small,
|
|
130
|
+
f"stdout={cat_result.stdout!r}",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# ── 8. write_file (large / presigned bypass) ───────────────────────
|
|
134
|
+
section('vm.sync.write_file(big_blob) — large payload uses presigned upload')
|
|
135
|
+
payload_big = secrets.token_bytes(8 * 1024 * 1024) # 8 MiB → presigned bypass
|
|
136
|
+
t0 = time.monotonic()
|
|
137
|
+
vm.sync.write_file("/home/user/big.bin", payload_big)
|
|
138
|
+
check("8 MiB write returned", True, f"{(time.monotonic()-t0)*1000:.0f}ms")
|
|
139
|
+
|
|
140
|
+
# ── 9. read_file (large / inline, since 8 MiB still fits) ──────────
|
|
141
|
+
section('vm.sync.read_file(big_blob) — handles inline-or-presigned automatically')
|
|
142
|
+
t0 = time.monotonic()
|
|
143
|
+
back_big = vm.sync.read_file("/home/user/big.bin")
|
|
144
|
+
check(
|
|
145
|
+
"8 MiB round-trip integrity",
|
|
146
|
+
hashlib.sha256(back_big).digest() == hashlib.sha256(payload_big).digest(),
|
|
147
|
+
f"{(time.monotonic()-t0)*1000:.0f}ms, sha256 match",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# ── 10. fork from existing VM (non-alias path) ────────────────────
|
|
151
|
+
section('vm.fork(name="branch") — fork an existing VM')
|
|
152
|
+
child = vm.fork(name="sdk-demo-child")
|
|
153
|
+
check(
|
|
154
|
+
"child has new id",
|
|
155
|
+
isinstance(child, Computer) and child.id != vm.id,
|
|
156
|
+
f"child.id={child.id}",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# ── 11. child sees parent's filesystem ────────────────────────────
|
|
160
|
+
section('child.run("cat /home/user/small.txt") — child inherits parent state')
|
|
161
|
+
child_cat = child.run("cat /home/user/small.txt")
|
|
162
|
+
check(
|
|
163
|
+
"child sees parent's file",
|
|
164
|
+
child_cat.exit_code == 0 and child_cat.stdout == payload_small,
|
|
165
|
+
f"stdout={child_cat.stdout!r}",
|
|
166
|
+
)
|
|
167
|
+
child.delete()
|
|
168
|
+
check("child.delete() succeeded", True)
|
|
169
|
+
|
|
170
|
+
# ── 12. error path — read missing file raises ArkerError ──────────
|
|
171
|
+
section("error path: vm.sync.read_file('/home/user/does-not-exist.txt')")
|
|
172
|
+
try:
|
|
173
|
+
vm.sync.read_file("/home/user/does-not-exist.txt")
|
|
174
|
+
check("missing file raises ArkerError", False, "no exception raised")
|
|
175
|
+
except ArkerError as err:
|
|
176
|
+
check(
|
|
177
|
+
"ArkerError(not_found, status=404)",
|
|
178
|
+
err.code == "not_found" and err.status == 404,
|
|
179
|
+
f"code={err.code!r} status={err.status}",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ── 13. list() shows our new VM ───────────────────────────────────
|
|
183
|
+
section('arker_client.list(q="sdk-demo") — filter shows the VMs we just made')
|
|
184
|
+
page_after = arker_client.list(q="sdk-demo")
|
|
185
|
+
check(
|
|
186
|
+
"list filters by name substring",
|
|
187
|
+
any(summary.vm_id == vm.id for summary in page_after),
|
|
188
|
+
f"total={page_after.total} matched",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
finally:
|
|
192
|
+
# ── 14. delete() — cleanup ────────────────────────────────────────
|
|
193
|
+
section("vm.delete() — cleanup")
|
|
194
|
+
try:
|
|
195
|
+
vm.delete()
|
|
196
|
+
check("vm.delete() succeeded", True)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
check("vm.delete() succeeded", False, str(e))
|
|
199
|
+
|
|
200
|
+
# ── Summary ────────────────────────────────────────────────────────────
|
|
201
|
+
total = len(results)
|
|
202
|
+
passed = sum(1 for _, ok, _ in results if ok)
|
|
203
|
+
print(f"\n━━━ SUMMARY ━━━\n {passed}/{total} passed")
|
|
204
|
+
if passed != total:
|
|
205
|
+
print(" Failures:")
|
|
206
|
+
for name, ok, detail in results:
|
|
207
|
+
if not ok:
|
|
208
|
+
print(f" × {name} [{detail}]")
|
|
209
|
+
sys.exit(0 if passed == total else 1)
|