runta-sdk 0.0.4__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.
asyncrunta/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """Async Runta Python SDK."""
2
+
3
+ from .client import AsyncRunta, Runtime, Session
4
+ from .errors import ApiError, CommandError, ConfigError, RuntaError
5
+ from .models import (
6
+ CommandResult,
7
+ CpuArch,
8
+ EgressAuditEvent,
9
+ EgressPolicy,
10
+ Injection,
11
+ IngressSpec,
12
+ RuntimeInfo,
13
+ RuntimeStatus,
14
+ SecretInfo,
15
+ SecretInjectionRuleInfo,
16
+ SecretRuleInfo,
17
+ SnapshotInfo,
18
+ SnapshotState,
19
+ )
20
+
21
+ Runta = AsyncRunta
22
+
23
+ __all__ = [
24
+ "ApiError",
25
+ "AsyncRunta",
26
+ "CommandError",
27
+ "CommandResult",
28
+ "ConfigError",
29
+ "CpuArch",
30
+ "EgressAuditEvent",
31
+ "EgressPolicy",
32
+ "Injection",
33
+ "IngressSpec",
34
+ "Runta",
35
+ "RuntaError",
36
+ "Runtime",
37
+ "RuntimeInfo",
38
+ "RuntimeStatus",
39
+ "SecretInfo",
40
+ "SecretInjectionRuleInfo",
41
+ "SecretRuleInfo",
42
+ "Session",
43
+ "SnapshotInfo",
44
+ "SnapshotState",
45
+ ]
asyncrunta/_config.py ADDED
@@ -0,0 +1,69 @@
1
+ """Configuration resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from pathlib import Path
8
+
9
+ from .errors import ConfigError
10
+
11
+
12
+ def resolve_config(
13
+ *,
14
+ endpoint: str | None,
15
+ token: str | None,
16
+ config_path: str | Path | None,
17
+ ) -> tuple[str, str]:
18
+ file_config = _read_config(config_path)
19
+ resolved_endpoint = _clean(endpoint) or _clean(os.getenv("RUNTA_ENDPOINT")) or file_config[0]
20
+ resolved_token = (
21
+ _clean(token)
22
+ or _clean(os.getenv("RUNTA_TOKEN"))
23
+ or _read_token_file(os.getenv("RUNTA_TOKEN_FILE"))
24
+ or file_config[1]
25
+ )
26
+ if not resolved_endpoint:
27
+ raise ConfigError(
28
+ "missing endpoint: pass endpoint=..., set RUNTA_ENDPOINT, or set endpoint in config"
29
+ )
30
+ if not resolved_token:
31
+ raise ConfigError("missing token: pass token=... or set RUNTA_TOKEN")
32
+ return resolved_endpoint, resolved_token
33
+
34
+
35
+ def _read_config(config_path: str | Path | None) -> tuple[str | None, str | None]:
36
+ path = (
37
+ Path(config_path).expanduser()
38
+ if config_path is not None
39
+ else Path(os.getenv("RUNTA_CONFIG", "~/.config/runta/config.toml")).expanduser()
40
+ )
41
+ if not path.exists():
42
+ return None, None
43
+ try:
44
+ data = tomllib.loads(path.read_text())
45
+ except OSError as exc:
46
+ raise ConfigError(f"failed to read config file {path}: {exc}") from exc
47
+ except tomllib.TOMLDecodeError as exc:
48
+ raise ConfigError(f"failed to parse config file {path}: {exc}") from exc
49
+ return _clean(data.get("endpoint")), _clean(data.get("token"))
50
+
51
+
52
+ def _read_token_file(token_file: str | None) -> str | None:
53
+ token_file = _clean(token_file)
54
+ if token_file is None:
55
+ return None
56
+ path = Path(token_file).expanduser()
57
+ if not path.exists():
58
+ return None
59
+ try:
60
+ return _clean(path.read_text())
61
+ except OSError as exc:
62
+ raise ConfigError(f"failed to read token file {path}: {exc}") from exc
63
+
64
+
65
+ def _clean(value: object) -> str | None:
66
+ if not isinstance(value, str):
67
+ return None
68
+ value = value.strip()
69
+ return value or None
asyncrunta/_http.py ADDED
@@ -0,0 +1,345 @@
1
+ """Low-level async REST client for runta-api."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from .errors import ApiError
13
+ from .models import (
14
+ CommandResult,
15
+ EgressAuditEvent,
16
+ EgressPolicy,
17
+ RuntimeInfo,
18
+ SecretInfo,
19
+ SecretInjectionRuleInfo,
20
+ SnapshotInfo,
21
+ )
22
+
23
+
24
+ class HttpClient:
25
+ """Small typed wrapper around runta-api's HTTP/JSON surface."""
26
+
27
+ def __init__(self, endpoint: str, token: str, timeout: float = 30.0) -> None:
28
+ self.endpoint = endpoint.rstrip("/")
29
+ self.token = token
30
+ self.timeout = timeout
31
+ self._runtime_base_path = "/v1/runtimes"
32
+ self._client = httpx.AsyncClient(
33
+ base_url=self.endpoint,
34
+ timeout=timeout,
35
+ trust_env=False,
36
+ headers={
37
+ "authorization": f"Bearer {token}",
38
+ "user-agent": "runta-python-sdk/0.1.0",
39
+ },
40
+ )
41
+
42
+ async def close(self) -> None:
43
+ await self._client.aclose()
44
+
45
+ async def request(
46
+ self,
47
+ method: str,
48
+ path: str,
49
+ *,
50
+ json: Mapping[str, Any] | None = None,
51
+ params: Mapping[str, Any] | None = None,
52
+ ) -> Any:
53
+ headers = {"x-request-id": str(uuid.uuid4())}
54
+ try:
55
+ response = await self._client.request(
56
+ method,
57
+ path,
58
+ json=json,
59
+ params=params,
60
+ headers=headers,
61
+ )
62
+ except httpx.HTTPError as exc:
63
+ raise ApiError(f"HTTP request failed: {exc}") from exc
64
+ if response.status_code >= 400:
65
+ raise _api_error(response)
66
+ if not response.content:
67
+ return None
68
+ payload = response.json()
69
+ return payload.get("data", payload)
70
+
71
+ async def runtime_request(
72
+ self,
73
+ method: str,
74
+ suffix: str = "",
75
+ *,
76
+ json: Mapping[str, Any] | None = None,
77
+ params: Mapping[str, Any] | None = None,
78
+ ) -> Any:
79
+ runtime_base_path = getattr(self, "_runtime_base_path", "/v1/runtimes")
80
+ path = f"{runtime_base_path}{suffix}"
81
+ try:
82
+ return await self.request(method, path, json=json, params=params)
83
+ except ApiError as exc:
84
+ if exc.status_code != 404 or runtime_base_path != "/v1/runtimes":
85
+ raise
86
+ legacy_path = f"/v1/sandboxes{suffix}"
87
+ data = await self.request(method, legacy_path, json=json, params=params)
88
+ self._runtime_base_path = "/v1/sandboxes"
89
+ return data
90
+
91
+ async def runtime_file_request(
92
+ self,
93
+ method: str,
94
+ runtime_id: str,
95
+ path: str,
96
+ *,
97
+ content: bytes | None = None,
98
+ ) -> httpx.Response:
99
+ headers = {"x-request-id": str(uuid.uuid4())}
100
+ if content is not None:
101
+ headers["content-type"] = "application/octet-stream"
102
+ runtime_base_path = getattr(self, "_runtime_base_path", "/v1/runtimes")
103
+ route = f"{runtime_base_path}/{runtime_id}/files"
104
+ try:
105
+ response = await self._client.request(
106
+ method,
107
+ route,
108
+ params={"path": path},
109
+ content=content,
110
+ headers=headers,
111
+ )
112
+ except httpx.HTTPError as exc:
113
+ raise ApiError(f"HTTP request failed: {exc}") from exc
114
+ if response.status_code == 404 and runtime_base_path == "/v1/runtimes":
115
+ legacy_route = f"/v1/sandboxes/{runtime_id}/files"
116
+ try:
117
+ response = await self._client.request(
118
+ method,
119
+ legacy_route,
120
+ params={"path": path},
121
+ content=content,
122
+ headers=headers,
123
+ )
124
+ except httpx.HTTPError as exc:
125
+ raise ApiError(f"HTTP request failed: {exc}") from exc
126
+ if response.status_code < 400:
127
+ self._runtime_base_path = "/v1/sandboxes"
128
+ if response.status_code >= 400:
129
+ raise _api_error(response)
130
+ return response
131
+
132
+ async def health(self) -> Any:
133
+ return await self.request("GET", "/healthz")
134
+
135
+ async def create_runtime(
136
+ self,
137
+ name: str | None = None,
138
+ *,
139
+ snapshot_id: str | None = None,
140
+ vcpus: int = 1,
141
+ cur_memory_mib: int = 1024,
142
+ min_memory_mib: int = 0,
143
+ max_memory_mib: int = 0,
144
+ ingress_specs: Sequence[dict[str, Any]] | None = None,
145
+ arch: str | None = None,
146
+ egress_policy: EgressPolicy | None = None,
147
+ idle_suspend_after_secs: int | None = None,
148
+ **_ignored: Any,
149
+ ) -> RuntimeInfo:
150
+ body: dict[str, Any] = {
151
+ "name": name,
152
+ "snapshot_id": snapshot_id,
153
+ "vcpus": vcpus,
154
+ "cur_memory_mib": cur_memory_mib,
155
+ "min_memory_mib": min_memory_mib,
156
+ "max_memory_mib": max_memory_mib,
157
+ "ingress_specs": list(ingress_specs or []),
158
+ "egress_policy": egress_policy.to_json() if egress_policy else EgressPolicy().to_json(),
159
+ "idle_suspend_after_secs": idle_suspend_after_secs,
160
+ }
161
+ if arch is not None:
162
+ body["arch"] = arch
163
+ return RuntimeInfo.from_json(await self.runtime_request("POST", json=_drop_none(body)))
164
+
165
+ async def list_runtimes(self, status: str | None = None) -> list[RuntimeInfo]:
166
+ params = {"status": status} if status else None
167
+ data = await self.runtime_request("GET", params=params)
168
+ return [RuntimeInfo.from_json(item) for item in data]
169
+
170
+ async def get_runtime(self, runtime_id: str) -> RuntimeInfo:
171
+ return RuntimeInfo.from_json(await self.runtime_request("GET", f"/{runtime_id}"))
172
+
173
+ async def delete_runtime(self, runtime_id: str) -> None:
174
+ await self.runtime_request("DELETE", f"/{runtime_id}")
175
+
176
+ async def read_file(self, runtime_id: str, path: str) -> bytes:
177
+ response = await self.runtime_file_request("GET", runtime_id, path)
178
+ return response.content
179
+
180
+ async def write_file(self, runtime_id: str, path: str, content: bytes | str) -> None:
181
+ if isinstance(content, str):
182
+ content = content.encode()
183
+ await self.runtime_file_request("PUT", runtime_id, path, content=content)
184
+
185
+ async def update_runtime_status(self, runtime_id: str, action: str) -> RuntimeInfo:
186
+ path = {
187
+ "running": "start",
188
+ "active": "start",
189
+ "paused": "pause",
190
+ "pause": "pause",
191
+ "resume": "resume",
192
+ "shutdown": "stop",
193
+ "stopped": "stop",
194
+ "stop": "stop",
195
+ }[action]
196
+ return RuntimeInfo.from_json(await self.runtime_request("POST", f"/{runtime_id}/{path}"))
197
+
198
+ async def enable_egress(self, runtime_id: str) -> RuntimeInfo:
199
+ return RuntimeInfo.from_json(await self.runtime_request("POST", f"/{runtime_id}/egress/enable"))
200
+
201
+ async def disable_egress(self, runtime_id: str) -> RuntimeInfo:
202
+ return RuntimeInfo.from_json(await self.runtime_request("POST", f"/{runtime_id}/egress/disable"))
203
+
204
+ async def get_egress(self, runtime_id: str) -> dict[str, Any]:
205
+ return await self.runtime_request("GET", f"/{runtime_id}/egress")
206
+
207
+ async def list_egress_audit(self, runtime_id: str, limit: int = 100) -> list[EgressAuditEvent]:
208
+ data = await self.runtime_request(
209
+ "GET",
210
+ f"/{runtime_id}/egress/audit",
211
+ params={"limit": limit},
212
+ )
213
+ return [EgressAuditEvent.from_json(item) for item in data]
214
+
215
+ async def add_allowlist_hosts(self, runtime_id: str, hosts: Sequence[str]) -> dict[str, Any]:
216
+ return await self.runtime_request(
217
+ "PATCH",
218
+ f"/{runtime_id}/egress/allowlist",
219
+ json={"add": list(hosts)},
220
+ )
221
+
222
+ async def remove_allowlist_hosts(self, runtime_id: str, hosts: Sequence[str]) -> dict[str, Any]:
223
+ return await self.runtime_request(
224
+ "PATCH",
225
+ f"/{runtime_id}/egress/allowlist",
226
+ json={"remove": list(hosts)},
227
+ )
228
+
229
+ async def execute(
230
+ self,
231
+ runtime_id: str,
232
+ command: str,
233
+ args: Sequence[str] = (),
234
+ *,
235
+ env: Mapping[str, str] | None = None,
236
+ stdin: bytes | str | None = None,
237
+ timeout: float | None = None,
238
+ cwd: str | None = None,
239
+ max_output_bytes: int | None = None,
240
+ ) -> CommandResult:
241
+ started = time.monotonic()
242
+ if isinstance(stdin, bytes):
243
+ stdin = stdin.decode(errors="replace")
244
+ body = {
245
+ "command": command,
246
+ "args": list(args),
247
+ "timeout_secs": int(timeout) if timeout is not None else None,
248
+ "env": dict(env) if env else None,
249
+ "stdin": stdin,
250
+ "cwd": cwd,
251
+ "max_output_bytes": max_output_bytes,
252
+ }
253
+ data = await self.runtime_request("POST", f"/{runtime_id}/exec", json=_drop_none(body))
254
+ return CommandResult(
255
+ exit_code=int(data["exit_code"]),
256
+ stdout=str(data.get("stdout", "")).encode(),
257
+ stderr=str(data.get("stderr", "")).encode(),
258
+ duration=float(data.get("duration_ms", 0)) / 1000.0 or time.monotonic() - started,
259
+ stdout_truncated=bool(data.get("stdout_truncated", False)),
260
+ stderr_truncated=bool(data.get("stderr_truncated", False)),
261
+ )
262
+
263
+ async def create_secret(self, name: str, value: str, cache_ttl_secs: int = 0) -> SecretInfo:
264
+ data = await self.request(
265
+ "POST",
266
+ "/v1/secrets",
267
+ json={"name": name, "value": value, "cache_ttl_secs": cache_ttl_secs},
268
+ )
269
+ return SecretInfo.from_json(data)
270
+
271
+ async def list_secrets(self) -> list[SecretInfo]:
272
+ return [SecretInfo.from_json(item) for item in await self.request("GET", "/v1/secrets")]
273
+
274
+ async def get_secret(self, secret_id: str) -> SecretInfo:
275
+ return SecretInfo.from_json(await self.request("GET", f"/v1/secrets/{secret_id}"))
276
+
277
+ async def delete_secret(self, secret_id: str) -> None:
278
+ await self.request("DELETE", f"/v1/secrets/{secret_id}")
279
+
280
+ async def create_secret_injection_rule(
281
+ self,
282
+ runtime_id: str,
283
+ host: str,
284
+ path: str | None,
285
+ secret: str,
286
+ injection: Mapping[str, Any],
287
+ ) -> SecretInjectionRuleInfo:
288
+ data = await self.runtime_request(
289
+ "POST",
290
+ f"/{runtime_id}/secret-injection-rules",
291
+ json=_drop_none({"host": host, "path": path, "secret": secret, "injection": dict(injection)}),
292
+ )
293
+ return SecretInjectionRuleInfo.from_json(data)
294
+
295
+ async def list_secret_injection_rules(self, runtime_id: str) -> list[SecretInjectionRuleInfo]:
296
+ data = await self.runtime_request("GET", f"/{runtime_id}/secret-injection-rules")
297
+ return [SecretInjectionRuleInfo.from_json(item) for item in data]
298
+
299
+ async def get_secret_injection_rule(self, runtime_id: str, rule_id: str) -> SecretInjectionRuleInfo:
300
+ data = await self.runtime_request("GET", f"/{runtime_id}/secret-injection-rules/{rule_id}")
301
+ return SecretInjectionRuleInfo.from_json(data)
302
+
303
+ async def delete_secret_injection_rule(self, runtime_id: str, rule_id: str) -> None:
304
+ await self.runtime_request("DELETE", f"/{runtime_id}/secret-injection-rules/{rule_id}")
305
+
306
+ create_secret_rule = create_secret_injection_rule
307
+ list_secret_rules = list_secret_injection_rules
308
+ get_secret_rule = get_secret_injection_rule
309
+ delete_secret_rule = delete_secret_injection_rule
310
+
311
+ async def create_snapshot(self, runtime_id: str, name: str | None = None, kind: str = "full") -> SnapshotInfo:
312
+ data = await self.runtime_request(
313
+ "POST",
314
+ f"/{runtime_id}/snapshots",
315
+ json=_drop_none({"name": name, "kind": kind}),
316
+ )
317
+ return SnapshotInfo.from_json(data)
318
+
319
+ async def list_snapshots(self) -> list[SnapshotInfo]:
320
+ return [SnapshotInfo.from_json(item) for item in await self.request("GET", "/v1/snapshots")]
321
+
322
+ async def get_snapshot(self, snapshot_id: str) -> SnapshotInfo:
323
+ return SnapshotInfo.from_json(await self.request("GET", f"/v1/snapshots/{snapshot_id}"))
324
+
325
+ async def delete_snapshot(self, snapshot_id: str) -> None:
326
+ await self.request("DELETE", f"/v1/snapshots/{snapshot_id}")
327
+
328
+
329
+ def _api_error(response: httpx.Response) -> ApiError:
330
+ code = None
331
+ request = response.request
332
+ message = response.text.strip()
333
+ try:
334
+ body = response.json().get("error", {})
335
+ code = body.get("code")
336
+ message = body.get("message") or message
337
+ except ValueError:
338
+ pass
339
+ if not message:
340
+ message = f"{request.method} {request.url.path} failed"
341
+ return ApiError(message, status_code=response.status_code, code=code)
342
+
343
+
344
+ def _drop_none(data: Mapping[str, Any]) -> dict[str, Any]:
345
+ return {key: value for key, value in data.items() if value is not None}