solari-core 0.2.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.
- solari_core-0.2.0/.gitignore +71 -0
- solari_core-0.2.0/PKG-INFO +28 -0
- solari_core-0.2.0/README.md +15 -0
- solari_core-0.2.0/pyproject.toml +20 -0
- solari_core-0.2.0/solari_core/__init__.py +51 -0
- solari_core-0.2.0/solari_core/_http.py +142 -0
- solari_core-0.2.0/solari_core/desktop.py +437 -0
- solari_core-0.2.0/solari_core/errors.py +148 -0
- solari_core-0.2.0/solari_core/handle.py +758 -0
- solari_core-0.2.0/solari_core/image.py +140 -0
- solari_core-0.2.0/solari_core/sandbox.py +34 -0
- solari_core-0.2.0/solari_core/template_client.py +203 -0
- solari_core-0.2.0/solari_core/transport.py +349 -0
- solari_core-0.2.0/solari_core/types.py +431 -0
- solari_core-0.2.0/solari_core/volume_client.py +115 -0
- solari_core-0.2.0/solari_core/ws.py +127 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# build artifacts
|
|
2
|
+
dist/
|
|
3
|
+
**/dist/
|
|
4
|
+
**/node_modules/
|
|
5
|
+
**/target/
|
|
6
|
+
*.log
|
|
7
|
+
|
|
8
|
+
# VM images / snapshots / kernels — large binaries, never committed
|
|
9
|
+
*.img
|
|
10
|
+
*.ext4
|
|
11
|
+
*.qcow2
|
|
12
|
+
*.raw
|
|
13
|
+
*.snapshot
|
|
14
|
+
/snapshots/build/
|
|
15
|
+
/guest/rootfs/build/
|
|
16
|
+
/guest/kernel/build/
|
|
17
|
+
vmlinux
|
|
18
|
+
bzImage
|
|
19
|
+
|
|
20
|
+
# terraform state
|
|
21
|
+
*.tfstate
|
|
22
|
+
*.tfstate.*
|
|
23
|
+
.terraform/
|
|
24
|
+
*.tfvars.local
|
|
25
|
+
|
|
26
|
+
# secrets / env
|
|
27
|
+
.env
|
|
28
|
+
.env.*
|
|
29
|
+
*.pem
|
|
30
|
+
|
|
31
|
+
# Python
|
|
32
|
+
__pycache__/
|
|
33
|
+
*.pyc
|
|
34
|
+
.venv/
|
|
35
|
+
|
|
36
|
+
# NOTE: tests/sdk/ts-request-fixtures.json is deliberately NOT ignored.
|
|
37
|
+
#
|
|
38
|
+
# It was, and that quietly disabled two gates at once:
|
|
39
|
+
#
|
|
40
|
+
# 1. ci.yml's "Committed fixture must match what the SDK emits today" step runs
|
|
41
|
+
# `git diff --exit-code tests/sdk/ts-request-fixtures.json`. On an ignored,
|
|
42
|
+
# never-tracked file that command can only ever succeed — it had never once
|
|
43
|
+
# been able to fail.
|
|
44
|
+
# 2. The Go/Rust/C++/Python contract suites now assert against that fixture from
|
|
45
|
+
# inside their OWN CI jobs, none of which run the TypeScript half. If the file
|
|
46
|
+
# is not committed, it is simply absent there.
|
|
47
|
+
#
|
|
48
|
+
# It is generated, but it is generated from the REFERENCE SDK and checked in as
|
|
49
|
+
# the baseline the other four bindings are held to — regenerating it and diffing
|
|
50
|
+
# is exactly how a wire change is made to announce itself.
|
|
51
|
+
|
|
52
|
+
# Built guest-agent binaries (produced by go build / build-rootfs staging)
|
|
53
|
+
guest/agent/agent
|
|
54
|
+
guest/rootfs/guest-agent
|
|
55
|
+
|
|
56
|
+
# build-rootfs.sh template staging + generated Dockerfile (regenerated each run)
|
|
57
|
+
guest/rootfs/guest-agent.service
|
|
58
|
+
guest/rootfs/template.packages
|
|
59
|
+
guest/rootfs/template-files/
|
|
60
|
+
guest/rootfs/.Dockerfile.gen
|
|
61
|
+
guest/rootfs/out/
|
|
62
|
+
|
|
63
|
+
# local test artifacts
|
|
64
|
+
staging-desktop.png
|
|
65
|
+
desktop.png
|
|
66
|
+
infra/terraform/snapshots-volumes/.build/
|
|
67
|
+
|
|
68
|
+
# deploy-e2e scratch state: holds a presigned REPO_URL with live STS creds
|
|
69
|
+
# (ASIA... + X-Amz-Security-Token). Never commit. Also stale PHASE_* here makes
|
|
70
|
+
# a bare re-run skip bake+verify and exit 0 with zero assertions.
|
|
71
|
+
infra/.deploy-e2e/
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: solari-core
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Shared runtime for the Solari VM SDKs (transport, session handles, image builder, types).
|
|
5
|
+
Project-URL: Homepage, https://getsolari.com
|
|
6
|
+
Author: Solari
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: desktop,microvm,sandbox,solari
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Requires-Dist: httpx>=0.24
|
|
11
|
+
Requires-Dist: websockets>=11.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# solari-core
|
|
15
|
+
|
|
16
|
+
Shared runtime for the Solari VM SDKs — the HTTP/WebSocket transport, the
|
|
17
|
+
`Desktop` and `Sandbox` session handles, the image builder, and the typed error
|
|
18
|
+
hierarchy. Mirrors the TypeScript `@solarisdk/core` package.
|
|
19
|
+
|
|
20
|
+
You normally install a leaf package instead:
|
|
21
|
+
|
|
22
|
+
- [`solari-desktop`](https://pypi.org/project/solari-desktop/) — computer-use desktops
|
|
23
|
+
- [`solari-sandbox`](https://pypi.org/project/solari-sandbox/) — code sandboxes
|
|
24
|
+
|
|
25
|
+
Both depend on and re-export `solari-core`, so `SandboxClient` / `DesktopClient`
|
|
26
|
+
plus the shared types are importable straight from the leaf package.
|
|
27
|
+
|
|
28
|
+
Docs: <https://getsolari.com>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# solari-core
|
|
2
|
+
|
|
3
|
+
Shared runtime for the Solari VM SDKs — the HTTP/WebSocket transport, the
|
|
4
|
+
`Desktop` and `Sandbox` session handles, the image builder, and the typed error
|
|
5
|
+
hierarchy. Mirrors the TypeScript `@solarisdk/core` package.
|
|
6
|
+
|
|
7
|
+
You normally install a leaf package instead:
|
|
8
|
+
|
|
9
|
+
- [`solari-desktop`](https://pypi.org/project/solari-desktop/) — computer-use desktops
|
|
10
|
+
- [`solari-sandbox`](https://pypi.org/project/solari-sandbox/) — code sandboxes
|
|
11
|
+
|
|
12
|
+
Both depend on and re-export `solari-core`, so `SandboxClient` / `DesktopClient`
|
|
13
|
+
plus the shared types are importable straight from the leaf package.
|
|
14
|
+
|
|
15
|
+
Docs: <https://getsolari.com>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "solari-core"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Shared runtime for the Solari VM SDKs (transport, session handles, image builder, types)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Solari" }]
|
|
13
|
+
keywords = ["solari", "sandbox", "desktop", "microvm"]
|
|
14
|
+
dependencies = ["httpx>=0.24", "websockets>=11.0"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://getsolari.com"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["solari_core"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""solari_core — shared runtime for the Solari VM SDKs.
|
|
2
|
+
|
|
3
|
+
The transport, session handles (:class:`Desktop`, :class:`Sandbox`), image
|
|
4
|
+
builder, and typed errors shared by ``solari-desktop`` and ``solari-sandbox``.
|
|
5
|
+
Mirrors the TypeScript ``@solarisdk/core`` package. You normally install one of
|
|
6
|
+
the leaf packages (``solari-desktop`` / ``solari-sandbox``), which re-export
|
|
7
|
+
this surface; import from here directly only for shared types.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._http import HttpTransport, new_idempotency_key
|
|
12
|
+
from .desktop import Desktop, DesktopConfig, ExecStreamHandler
|
|
13
|
+
from .handle import SessionConfig, SessionHandle, SessionHooks
|
|
14
|
+
from .image import CompiledImage, Image, LocalCopy
|
|
15
|
+
from .sandbox import Sandbox
|
|
16
|
+
from .template_client import SyncTemplateClient, TemplateClient
|
|
17
|
+
from .volume_client import SyncVolumeClient, VolumeClient
|
|
18
|
+
from .errors import (
|
|
19
|
+
ActionError, AuthError, ConcurrencyLimitError, ConnectionError,
|
|
20
|
+
GatewayError, NoCapacityError, SolariError, PlanError, TimeoutError,
|
|
21
|
+
)
|
|
22
|
+
from .types import (
|
|
23
|
+
CodeLanguage, CodeResultItem, CommandResult, CreateDesktopResponse,
|
|
24
|
+
CreateSandboxResponse, DeleteDesktopResponse, DesktopLifecycleResponse,
|
|
25
|
+
DesktopStatus, ExecResult, ExecStreamChunk, FsEntry, FsSearchMatch, FsStat,
|
|
26
|
+
FsWatchEvent, GatewayErrorBody, GitBranch, GitCommit, GitStatus,
|
|
27
|
+
GetDesktopResponse, HealthResult, KeyAction, MetricsResult, MouseAction,
|
|
28
|
+
MouseButton, PackageManager, PkgInstallResult, PortInfo, ProcessInfo,
|
|
29
|
+
RpcErrorBody, RpcResponse, RunCodeResult, SandboxKind, SandboxState,
|
|
30
|
+
SandboxView, ScreenshotFormat, SnapshotView,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__version__ = "0.2.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"HttpTransport", "new_idempotency_key",
|
|
37
|
+
"Image", "CompiledImage", "LocalCopy",
|
|
38
|
+
"TemplateClient", "SyncTemplateClient", "VolumeClient", "SyncVolumeClient",
|
|
39
|
+
"Desktop", "DesktopConfig", "ExecStreamHandler", "Sandbox",
|
|
40
|
+
"SessionHandle", "SessionConfig", "SessionHooks",
|
|
41
|
+
"ActionError", "AuthError", "ConcurrencyLimitError", "ConnectionError",
|
|
42
|
+
"GatewayError", "NoCapacityError", "SolariError", "PlanError", "TimeoutError",
|
|
43
|
+
"CreateDesktopResponse", "CreateSandboxResponse", "DeleteDesktopResponse",
|
|
44
|
+
"DesktopLifecycleResponse", "DesktopStatus", "ExecResult", "ExecStreamChunk",
|
|
45
|
+
"FsEntry", "FsStat", "GatewayErrorBody", "GetDesktopResponse", "HealthResult",
|
|
46
|
+
"KeyAction", "MouseAction", "MouseButton", "PackageManager", "PkgInstallResult",
|
|
47
|
+
"PortInfo", "ProcessInfo", "RpcErrorBody", "RpcResponse", "ScreenshotFormat",
|
|
48
|
+
"CodeLanguage", "CodeResultItem", "CommandResult", "FsSearchMatch",
|
|
49
|
+
"FsWatchEvent", "GitBranch", "GitCommit", "GitStatus", "MetricsResult",
|
|
50
|
+
"RunCodeResult", "SandboxKind", "SandboxState", "SandboxView", "SnapshotView",
|
|
51
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Shared async HTTP transport (mirrors the TypeScript ``src/http.ts``).
|
|
2
|
+
|
|
3
|
+
Both :class:`DesktopClient` and :class:`SandboxClient` delegate here so there is
|
|
4
|
+
ONE place owning auth headers, error mapping, retries/backoff, idempotency keys,
|
|
5
|
+
and timeouts.
|
|
6
|
+
|
|
7
|
+
Retry policy: idempotent requests (GET, DELETE, or any carrying an
|
|
8
|
+
Idempotency-Key) are retried on network errors, HTTP 5xx, and bodies flagged
|
|
9
|
+
``retryable``, with exponential backoff + jitter. Non-idempotent writes are
|
|
10
|
+
never silently retried — pass ``idempotency_key`` to opt a create into safe
|
|
11
|
+
retries. 429 is NOT retried (it is our ConcurrencyLimitError).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import random
|
|
17
|
+
import uuid
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .errors import ConnectionError as PtConnectionError
|
|
23
|
+
from .errors import SolariError, map_gateway_error
|
|
24
|
+
from .types import GatewayErrorBody
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def new_idempotency_key() -> str:
|
|
28
|
+
"""A fresh idempotency key (UUID)."""
|
|
29
|
+
return str(uuid.uuid4())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HttpTransport:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
api_key: str,
|
|
37
|
+
base_url: str,
|
|
38
|
+
http: Optional[httpx.AsyncClient] = None,
|
|
39
|
+
max_retries: int = 5,
|
|
40
|
+
request_timeout_ms: int = 300_000,
|
|
41
|
+
retry_delay_ms: Optional[int] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
if not api_key:
|
|
44
|
+
raise SolariError("HttpTransport requires an api_key")
|
|
45
|
+
if not base_url:
|
|
46
|
+
raise SolariError("HttpTransport requires a base_url")
|
|
47
|
+
self._api_key = api_key
|
|
48
|
+
self._base_url = base_url.rstrip("/")
|
|
49
|
+
self._http = http
|
|
50
|
+
self._owns_http = http is None
|
|
51
|
+
self._max_retries = max_retries
|
|
52
|
+
self._timeout = request_timeout_ms / 1000.0
|
|
53
|
+
self._retry_delay_ms = retry_delay_ms
|
|
54
|
+
|
|
55
|
+
def _client(self) -> httpx.AsyncClient:
|
|
56
|
+
if self._http is None:
|
|
57
|
+
self._http = httpx.AsyncClient()
|
|
58
|
+
return self._http
|
|
59
|
+
|
|
60
|
+
def auth_headers(self) -> Dict[str, str]:
|
|
61
|
+
return {"Authorization": f"Bearer {self._api_key}"}
|
|
62
|
+
|
|
63
|
+
def ws_origin(self) -> str:
|
|
64
|
+
if self._base_url.startswith("https"):
|
|
65
|
+
return "wss" + self._base_url[len("https"):]
|
|
66
|
+
if self._base_url.startswith("http"):
|
|
67
|
+
return "ws" + self._base_url[len("http"):]
|
|
68
|
+
return self._base_url
|
|
69
|
+
|
|
70
|
+
async def request(
|
|
71
|
+
self,
|
|
72
|
+
method: str,
|
|
73
|
+
path: str,
|
|
74
|
+
body: Optional[Any] = None,
|
|
75
|
+
*,
|
|
76
|
+
idempotency_key: Optional[str] = None,
|
|
77
|
+
) -> Any:
|
|
78
|
+
idempotent = method in ("GET", "DELETE") or idempotency_key is not None
|
|
79
|
+
headers: Dict[str, str] = {
|
|
80
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
81
|
+
"Accept": "application/json",
|
|
82
|
+
}
|
|
83
|
+
if body is not None:
|
|
84
|
+
headers["Content-Type"] = "application/json"
|
|
85
|
+
if idempotency_key:
|
|
86
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
87
|
+
|
|
88
|
+
attempt = 0
|
|
89
|
+
while True:
|
|
90
|
+
try:
|
|
91
|
+
res = await self._client().request(
|
|
92
|
+
method,
|
|
93
|
+
f"{self._base_url}{path}",
|
|
94
|
+
headers=headers,
|
|
95
|
+
json=body if body is not None else None,
|
|
96
|
+
timeout=self._timeout,
|
|
97
|
+
)
|
|
98
|
+
except httpx.HTTPError as exc:
|
|
99
|
+
if idempotent and attempt < self._max_retries:
|
|
100
|
+
await asyncio.sleep(self._backoff(attempt))
|
|
101
|
+
attempt += 1
|
|
102
|
+
continue
|
|
103
|
+
raise PtConnectionError(f"{method} {path} failed: {exc}") from exc
|
|
104
|
+
|
|
105
|
+
if res.is_error:
|
|
106
|
+
err_body: Optional[GatewayErrorBody] = None
|
|
107
|
+
try:
|
|
108
|
+
parsed = res.json()
|
|
109
|
+
if isinstance(parsed, dict):
|
|
110
|
+
err_body = GatewayErrorBody(
|
|
111
|
+
code=parsed.get("code"),
|
|
112
|
+
error=parsed.get("error"),
|
|
113
|
+
message=parsed.get("message"),
|
|
114
|
+
retryable=parsed.get("retryable"),
|
|
115
|
+
)
|
|
116
|
+
except Exception: # noqa: BLE001 - body may be empty/non-JSON
|
|
117
|
+
err_body = None
|
|
118
|
+
# 5xx or explicit retryable hint; NOT 429 (ConcurrencyLimitError).
|
|
119
|
+
retryable = res.status_code >= 500 or (
|
|
120
|
+
err_body is not None and err_body.retryable is True
|
|
121
|
+
)
|
|
122
|
+
if idempotent and retryable and attempt < self._max_retries:
|
|
123
|
+
await asyncio.sleep(self._backoff(attempt))
|
|
124
|
+
attempt += 1
|
|
125
|
+
continue
|
|
126
|
+
raise map_gateway_error(res.status_code, err_body)
|
|
127
|
+
|
|
128
|
+
if not res.content:
|
|
129
|
+
return None
|
|
130
|
+
return res.json()
|
|
131
|
+
|
|
132
|
+
def _backoff(self, attempt: int) -> float:
|
|
133
|
+
if self._retry_delay_ms is not None:
|
|
134
|
+
return self._retry_delay_ms / 1000.0
|
|
135
|
+
# Base 150ms (was 1000): a create bounced by a transient host no_capacity
|
|
136
|
+
# during a burst re-picks a different host in ~150ms, not a full second.
|
|
137
|
+
return min(0.15 * 2 ** attempt, 8.0) + random.random() * 0.25
|
|
138
|
+
|
|
139
|
+
async def aclose(self) -> None:
|
|
140
|
+
if self._owns_http and self._http is not None:
|
|
141
|
+
await self._http.aclose()
|
|
142
|
+
self._http = None
|