anona-compute 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.
- anona_compute-0.2.0/.gitignore +9 -0
- anona_compute-0.2.0/PKG-INFO +66 -0
- anona_compute-0.2.0/README.md +50 -0
- anona_compute-0.2.0/anona_compute/__init__.py +55 -0
- anona_compute-0.2.0/anona_compute/_config.py +82 -0
- anona_compute-0.2.0/anona_compute/_http.py +78 -0
- anona_compute-0.2.0/anona_compute/desktop.py +56 -0
- anona_compute-0.2.0/anona_compute/idle.py +72 -0
- anona_compute-0.2.0/anona_compute/live.py +106 -0
- anona_compute-0.2.0/anona_compute/sandbox.py +94 -0
- anona_compute-0.2.0/anona_compute/ssh.py +133 -0
- anona_compute-0.2.0/pyproject.toml +37 -0
- anona_compute-0.2.0/tests/conftest.py +32 -0
- anona_compute-0.2.0/tests/fake_gateway.py +145 -0
- anona_compute-0.2.0/tests/test_config.py +68 -0
- anona_compute-0.2.0/tests/test_desktop.py +25 -0
- anona_compute-0.2.0/tests/test_gateway_it.py +39 -0
- anona_compute-0.2.0/tests/test_live.py +77 -0
- anona_compute-0.2.0/tests/test_size.py +28 -0
- anona_compute-0.2.0/tests/test_ssh.py +88 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: anona-compute
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Anona Compute extras on top of the E2B SDK
|
|
5
|
+
Project-URL: Homepage, https://compute.anonalabs.com
|
|
6
|
+
Project-URL: Documentation, https://compute.anonalabs.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/anonalabs/anona-compute-sdk
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: e2b<3,>=2.61
|
|
11
|
+
Provides-Extra: desktop
|
|
12
|
+
Requires-Dist: e2b-desktop<3,>=2.7.2; extra == 'desktop'
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest==9.1.1; extra == 'test'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# anona-compute (Python)
|
|
18
|
+
|
|
19
|
+
Optional extras for [Anona Compute](https://compute.anonalabs.com) on top of the stock E2B SDK.
|
|
20
|
+
The stock SDK already works on its own:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
export E2B_DOMAIN=compute.anonalabs.com
|
|
24
|
+
export E2B_API_KEY=ac_... # an API key from https://compute.anonalabs.com
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This package adds sandbox sizes, live view tokens and SSH certificates.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install anona-compute
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import anona_compute
|
|
35
|
+
|
|
36
|
+
anona_compute.configure(api_key="ac_...") # or set ANONA_API_KEY / E2B_API_KEY
|
|
37
|
+
sbx = anona_compute.Sandbox.create("python", size="large") # a subclass of e2b.Sandbox
|
|
38
|
+
|
|
39
|
+
url = sbx.live.url(mode="dom") # view-only embed URL, 600 s
|
|
40
|
+
tok = sbx.live.token(scope="control", channels=["pty"], ttl=120)
|
|
41
|
+
sbx.live.revoke_all()
|
|
42
|
+
|
|
43
|
+
cert = sbx.ssh.certificate("~/.ssh/anona_ed25519.pub")
|
|
44
|
+
cert.write("~/.ssh/anona_ed25519-cert.pub")
|
|
45
|
+
print(sbx.ssh.config(identity_file="~/.ssh/anona_ed25519"))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Environment mapping: `ANONA_API_KEY` → `E2B_API_KEY`, `ANONA_DOMAIN` → `E2B_DOMAIN`
|
|
49
|
+
(default `compute.anonalabs.com`), `ANONA_API_URL` → `E2B_API_URL` (deprecated by E2B, fallback only).
|
|
50
|
+
`E2B_*` values always win.
|
|
51
|
+
|
|
52
|
+
`size` is `small` (1 vCPU, 2 GiB, 10 GB disk), `medium` (2, 4 GiB, 20 GB), `large` (4, 8 GiB, 40 GB) or `xl` (8, 16 GiB, 80 GB). Without it a sandbox runs at its template's own size.
|
|
53
|
+
|
|
54
|
+
Install the desktop extra with `pip install "anona-compute[desktop]"`.
|
|
55
|
+
|
|
56
|
+
This is the Python library only. The `anona-compute` command-line tool is a separate npm package: `npm install -g @anona-labs/anona-compute`.
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
uv venv -p 3.12 .venv && uv pip install -p .venv/bin/python -e ".[test]"
|
|
62
|
+
.venv/bin/python -m pytest -q # fake gateway, no network
|
|
63
|
+
GATEWAY_URL=... GATEWAY_API_KEY=... .venv/bin/python -m pytest -m gateway
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The version comes from the git tag `sdk-v<version>` (hatch-vcs); untagged builds get a `0.0.1.devN+g<sha>` development version.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# anona-compute (Python)
|
|
2
|
+
|
|
3
|
+
Optional extras for [Anona Compute](https://compute.anonalabs.com) on top of the stock E2B SDK.
|
|
4
|
+
The stock SDK already works on its own:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
export E2B_DOMAIN=compute.anonalabs.com
|
|
8
|
+
export E2B_API_KEY=ac_... # an API key from https://compute.anonalabs.com
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This package adds sandbox sizes, live view tokens and SSH certificates.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install anona-compute
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import anona_compute
|
|
19
|
+
|
|
20
|
+
anona_compute.configure(api_key="ac_...") # or set ANONA_API_KEY / E2B_API_KEY
|
|
21
|
+
sbx = anona_compute.Sandbox.create("python", size="large") # a subclass of e2b.Sandbox
|
|
22
|
+
|
|
23
|
+
url = sbx.live.url(mode="dom") # view-only embed URL, 600 s
|
|
24
|
+
tok = sbx.live.token(scope="control", channels=["pty"], ttl=120)
|
|
25
|
+
sbx.live.revoke_all()
|
|
26
|
+
|
|
27
|
+
cert = sbx.ssh.certificate("~/.ssh/anona_ed25519.pub")
|
|
28
|
+
cert.write("~/.ssh/anona_ed25519-cert.pub")
|
|
29
|
+
print(sbx.ssh.config(identity_file="~/.ssh/anona_ed25519"))
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Environment mapping: `ANONA_API_KEY` → `E2B_API_KEY`, `ANONA_DOMAIN` → `E2B_DOMAIN`
|
|
33
|
+
(default `compute.anonalabs.com`), `ANONA_API_URL` → `E2B_API_URL` (deprecated by E2B, fallback only).
|
|
34
|
+
`E2B_*` values always win.
|
|
35
|
+
|
|
36
|
+
`size` is `small` (1 vCPU, 2 GiB, 10 GB disk), `medium` (2, 4 GiB, 20 GB), `large` (4, 8 GiB, 40 GB) or `xl` (8, 16 GiB, 80 GB). Without it a sandbox runs at its template's own size.
|
|
37
|
+
|
|
38
|
+
Install the desktop extra with `pip install "anona-compute[desktop]"`.
|
|
39
|
+
|
|
40
|
+
This is the Python library only. The `anona-compute` command-line tool is a separate npm package: `npm install -g @anona-labs/anona-compute`.
|
|
41
|
+
|
|
42
|
+
## Development
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
uv venv -p 3.12 .venv && uv pip install -p .venv/bin/python -e ".[test]"
|
|
46
|
+
.venv/bin/python -m pytest -q # fake gateway, no network
|
|
47
|
+
GATEWAY_URL=... GATEWAY_API_KEY=... .venv/bin/python -m pytest -m gateway
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The version comes from the git tag `sdk-v<version>` (hatch-vcs); untagged builds get a `0.0.1.devN+g<sha>` development version.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Anona Compute extras on top of the E2B SDK.
|
|
2
|
+
|
|
3
|
+
The stock E2B SDK works against Anona with ``E2B_DOMAIN=compute.anonalabs.com``
|
|
4
|
+
and ``E2B_API_KEY``; this package is optional. It adds:
|
|
5
|
+
|
|
6
|
+
- ``anona_compute.configure(api_key=...)`` and ``anona_compute.Sandbox``, a subclass of
|
|
7
|
+
``e2b.Sandbox`` that defaults to the Anona domain.
|
|
8
|
+
- ``sandbox.live``: viewer tokens and embed URLs for the live view.
|
|
9
|
+
- ``sandbox.ssh``: short-lived SSH certificates and an ``ssh_config`` snippet.
|
|
10
|
+
- ``sandbox.idle_pause``: pause the sandbox once nothing has used it for a while.
|
|
11
|
+
|
|
12
|
+
Environment mapping (only variables the E2B SDK 2.61.0 reads in
|
|
13
|
+
``e2b/connection_config.py`` are targeted):
|
|
14
|
+
|
|
15
|
+
================== ============== ==========================================
|
|
16
|
+
Anona variable E2B variable Notes
|
|
17
|
+
================== ============== ==========================================
|
|
18
|
+
``ANONA_API_KEY`` ``E2B_API_KEY`` used when ``E2B_API_KEY`` is unset
|
|
19
|
+
``ANONA_DOMAIN`` ``E2B_DOMAIN`` default ``compute.anonalabs.com``
|
|
20
|
+
``ANONA_API_URL`` ``E2B_API_URL`` deprecated by E2B; fallback only
|
|
21
|
+
================== ============== ==========================================
|
|
22
|
+
|
|
23
|
+
``E2B_*`` values always win over ``ANONA_*`` values.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
27
|
+
|
|
28
|
+
from anona_compute._config import DEFAULT_API_URL, DEFAULT_DOMAIN, ENV_MAP, configure
|
|
29
|
+
from anona_compute.idle import MAX_IDLE_PAUSE_MINUTES, IdlePause
|
|
30
|
+
from anona_compute.live import Live, ViewerToken
|
|
31
|
+
from anona_compute.sandbox import SIZES, Sandbox, SandboxSize, size_metadata
|
|
32
|
+
from anona_compute.ssh import Ssh, SshCertificate
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
__version__ = _version("anona-compute")
|
|
36
|
+
except PackageNotFoundError: # running from a source checkout
|
|
37
|
+
__version__ = "0.0.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"DEFAULT_API_URL",
|
|
41
|
+
"DEFAULT_DOMAIN",
|
|
42
|
+
"ENV_MAP",
|
|
43
|
+
"MAX_IDLE_PAUSE_MINUTES",
|
|
44
|
+
"IdlePause",
|
|
45
|
+
"Live",
|
|
46
|
+
"SIZES",
|
|
47
|
+
"Sandbox",
|
|
48
|
+
"SandboxSize",
|
|
49
|
+
"Ssh",
|
|
50
|
+
"SshCertificate",
|
|
51
|
+
"ViewerToken",
|
|
52
|
+
"size_metadata",
|
|
53
|
+
"__version__",
|
|
54
|
+
"configure",
|
|
55
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Environment mapping between Anona settings and the variables the E2B SDK reads.
|
|
2
|
+
|
|
3
|
+
The E2B Python SDK 2.61.0 reads these variables in ``e2b/connection_config.py``:
|
|
4
|
+
``E2B_API_KEY``, ``E2B_DOMAIN``, ``E2B_API_URL`` (deprecated), ``E2B_SANDBOX_URL``
|
|
5
|
+
(deprecated), ``E2B_DEBUG`` and ``E2B_HTTP_VERSION``. Only the first four that
|
|
6
|
+
matter for pointing the SDK at Anona are mapped here; no new E2B variables are
|
|
7
|
+
invented.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Mapping, MutableMapping, Optional
|
|
14
|
+
|
|
15
|
+
DEFAULT_DOMAIN = "compute.anonalabs.com"
|
|
16
|
+
"""Domain customers set as ``E2B_DOMAIN``. The SDK derives ``https://api.<domain>``."""
|
|
17
|
+
|
|
18
|
+
DEFAULT_API_URL = f"https://api.{DEFAULT_DOMAIN}"
|
|
19
|
+
"""Public API endpoint. Only used directly by the Anona extras."""
|
|
20
|
+
|
|
21
|
+
# Anona variable -> E2B variable. ANONA_* wins only when the E2B variable is unset,
|
|
22
|
+
# so a customer who already exported E2B_* keeps full control.
|
|
23
|
+
ENV_MAP: Mapping[str, str] = {
|
|
24
|
+
"ANONA_API_KEY": "E2B_API_KEY",
|
|
25
|
+
"ANONA_DOMAIN": "E2B_DOMAIN",
|
|
26
|
+
"ANONA_API_URL": "E2B_API_URL",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
COMPUTE_KEY_PREFIXES = ("ac_", "e2b_")
|
|
31
|
+
"""Prefixes of an Anona Compute API key (``ac_``, legacy ``e2b_``)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compute_key(value: Optional[str]) -> Optional[str]:
|
|
35
|
+
"""``value`` if it is an Anona Compute key, else ``None``.
|
|
36
|
+
|
|
37
|
+
``ANONA_API_KEY`` is shared with Anona Memory, whose key must never be
|
|
38
|
+
sent to the compute API. The ``anona-compute`` CLI applies the same rule.
|
|
39
|
+
"""
|
|
40
|
+
if value and value.startswith(COMPUTE_KEY_PREFIXES):
|
|
41
|
+
return value
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def apply_env(environ: Optional[MutableMapping[str, str]] = None) -> None:
|
|
46
|
+
"""Copy ``ANONA_*`` values to the ``E2B_*`` names and default ``E2B_DOMAIN``.
|
|
47
|
+
|
|
48
|
+
``ANONA_API_KEY`` is copied only when it holds a compute key.
|
|
49
|
+
"""
|
|
50
|
+
env = os.environ if environ is None else environ
|
|
51
|
+
for src, dst in ENV_MAP.items():
|
|
52
|
+
value = env.get(src)
|
|
53
|
+
if src == "ANONA_API_KEY":
|
|
54
|
+
value = compute_key(value)
|
|
55
|
+
if value and not env.get(dst):
|
|
56
|
+
env[dst] = value
|
|
57
|
+
if not env.get("E2B_DOMAIN"):
|
|
58
|
+
env["E2B_DOMAIN"] = DEFAULT_DOMAIN
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def configure(
|
|
62
|
+
api_key: Optional[str] = None,
|
|
63
|
+
*,
|
|
64
|
+
domain: Optional[str] = None,
|
|
65
|
+
api_url: Optional[str] = None,
|
|
66
|
+
debug: Optional[bool] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Point the stock E2B SDK (and the Anona extras) at Anona Compute.
|
|
69
|
+
|
|
70
|
+
Explicit arguments overwrite the matching ``E2B_*`` variables in this
|
|
71
|
+
process. Missing values fall back to ``ANONA_*`` variables, then to the
|
|
72
|
+
defaults (``E2B_DOMAIN=compute.anonalabs.com``).
|
|
73
|
+
"""
|
|
74
|
+
if api_key is not None:
|
|
75
|
+
os.environ["E2B_API_KEY"] = api_key
|
|
76
|
+
if domain is not None:
|
|
77
|
+
os.environ["E2B_DOMAIN"] = domain
|
|
78
|
+
if api_url is not None:
|
|
79
|
+
os.environ["E2B_API_URL"] = api_url
|
|
80
|
+
if debug is not None:
|
|
81
|
+
os.environ["E2B_DEBUG"] = "true" if debug else "false"
|
|
82
|
+
apply_env()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Small HTTP helper for the Anona-only gateway endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from e2b.connection_config import ConnectionConfig
|
|
9
|
+
from e2b.exceptions import (
|
|
10
|
+
AuthenticationException,
|
|
11
|
+
InvalidArgumentException,
|
|
12
|
+
NotFoundException,
|
|
13
|
+
RateLimitException,
|
|
14
|
+
SandboxException,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _error(resp: httpx.Response) -> Exception:
|
|
19
|
+
try:
|
|
20
|
+
body = resp.json()
|
|
21
|
+
message = body.get("message") or resp.text
|
|
22
|
+
except ValueError:
|
|
23
|
+
message = resp.text
|
|
24
|
+
message = f"{resp.status_code}: {message}"
|
|
25
|
+
if resp.status_code == 400:
|
|
26
|
+
return InvalidArgumentException(message)
|
|
27
|
+
if resp.status_code in (401, 403):
|
|
28
|
+
return AuthenticationException(message)
|
|
29
|
+
if resp.status_code == 404:
|
|
30
|
+
return NotFoundException(message)
|
|
31
|
+
if resp.status_code == 429:
|
|
32
|
+
return RateLimitException(message)
|
|
33
|
+
return SandboxException(message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _request(
|
|
37
|
+
config: ConnectionConfig,
|
|
38
|
+
method: str,
|
|
39
|
+
path: str,
|
|
40
|
+
body: Optional[Dict[str, Any]] = None,
|
|
41
|
+
) -> Optional[Dict[str, Any]]:
|
|
42
|
+
headers = dict(config.headers)
|
|
43
|
+
if config.api_key:
|
|
44
|
+
headers["X-API-Key"] = config.api_key
|
|
45
|
+
with httpx.Client(
|
|
46
|
+
base_url=config.api_url,
|
|
47
|
+
timeout=config.request_timeout,
|
|
48
|
+
proxy=config.proxy, # type: ignore[arg-type]
|
|
49
|
+
) as client:
|
|
50
|
+
resp = client.request(method, path, json=body, headers=headers)
|
|
51
|
+
if resp.status_code >= 400:
|
|
52
|
+
raise _error(resp)
|
|
53
|
+
if resp.status_code == 204 or not resp.content:
|
|
54
|
+
return None
|
|
55
|
+
return resp.json()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def post_json(
|
|
59
|
+
config: ConnectionConfig,
|
|
60
|
+
path: str,
|
|
61
|
+
body: Optional[Dict[str, Any]] = None,
|
|
62
|
+
) -> Optional[Dict[str, Any]]:
|
|
63
|
+
"""POST to ``<api_url><path>`` with the sandbox's API key; return the JSON body."""
|
|
64
|
+
return _request(config, "POST", path, body)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def put_json(
|
|
68
|
+
config: ConnectionConfig,
|
|
69
|
+
path: str,
|
|
70
|
+
body: Optional[Dict[str, Any]] = None,
|
|
71
|
+
) -> Optional[Dict[str, Any]]:
|
|
72
|
+
"""PUT to ``<api_url><path>`` with the sandbox's API key; return the JSON body."""
|
|
73
|
+
return _request(config, "PUT", path, body)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_json(config: ConnectionConfig, path: str) -> Optional[Dict[str, Any]]:
|
|
77
|
+
"""GET ``<api_url><path>`` with the sandbox's API key; return the JSON body."""
|
|
78
|
+
return _request(config, "GET", path)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""``anona_compute.desktop.Sandbox``: the stock ``e2b_desktop.Sandbox`` plus Anona extras.
|
|
2
|
+
|
|
3
|
+
Every e2b-desktop call (mouse, keyboard, screenshot, ``stream.start`` and the
|
|
4
|
+
noVNC ``stream.get_url``) is inherited unchanged. The additions are the same
|
|
5
|
+
``live`` and ``ssh`` helpers as ``anona_compute.Sandbox`` and ``live_url()``, the
|
|
6
|
+
Anona live-view link for the desktop channel. That channel is served by the
|
|
7
|
+
template's guest broker (VNC on 127.0.0.1:5901, started on first use), so it
|
|
8
|
+
needs no ``stream.start()`` and never exposes port 6080.
|
|
9
|
+
|
|
10
|
+
Install with ``pip install "anona-compute[desktop]"``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Unpack
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import e2b_desktop
|
|
19
|
+
except ImportError as e: # pragma: no cover - exercised only without the extra
|
|
20
|
+
raise ImportError('anona_compute.desktop needs e2b-desktop: pip install "anona-compute[desktop]"') from e
|
|
21
|
+
|
|
22
|
+
from e2b.connection_config import ApiParams
|
|
23
|
+
|
|
24
|
+
from anona_compute.idle import IdlePause
|
|
25
|
+
from anona_compute.live import MAX_TTL, Live
|
|
26
|
+
from anona_compute.sandbox import anona_defaults
|
|
27
|
+
from anona_compute.ssh import Ssh
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Sandbox(e2b_desktop.Sandbox):
|
|
31
|
+
"""``e2b_desktop.Sandbox`` with ``live``, ``ssh``, ``idle_pause`` and ``live_url()``."""
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def _resolve_api_params(cls, **opts: Unpack[ApiParams]) -> ApiParams:
|
|
35
|
+
merged = dict(anona_defaults())
|
|
36
|
+
resolved = super()._resolve_api_params(**opts)
|
|
37
|
+
merged.update({k: v for k, v in resolved.items() if v is not None})
|
|
38
|
+
return merged # type: ignore[return-value]
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def live(self) -> Live:
|
|
42
|
+
return Live(self.sandbox_id, self.connection_config)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def ssh(self) -> Ssh:
|
|
46
|
+
return Ssh(self.sandbox_id, self.connection_config)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def idle_pause(self) -> IdlePause:
|
|
50
|
+
"""Idle pause: ``get()``, ``set(minutes)``, ``off()``, as on ``anona_compute.Sandbox``."""
|
|
51
|
+
return IdlePause(self.sandbox_id, self.connection_config)
|
|
52
|
+
|
|
53
|
+
def live_url(self, control: bool = True, ttl: int = MAX_TTL) -> str:
|
|
54
|
+
"""Embed URL for the desktop channel (control scope by default: the
|
|
55
|
+
desktop channel carries RFB input, which a view token may not send)."""
|
|
56
|
+
return self.live.url(mode="desktop", scope="control" if control else "view", ttl=ttl)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Idle pause: pause a sandbox once nothing has used it for a while.
|
|
2
|
+
|
|
3
|
+
Gateway contract (Anona routes, not E2B):
|
|
4
|
+
|
|
5
|
+
- ``PUT /v1/sandboxes/{id}/auto-pause`` with ``{"idleMinutes": n}`` returns the
|
|
6
|
+
sandbox's access settings. ``n`` is 1 to 10080 (one week); ``0`` turns idle
|
|
7
|
+
pausing off.
|
|
8
|
+
- ``GET /v1/sandboxes/{id}/access`` returns ``idlePauseMinutes`` among them.
|
|
9
|
+
|
|
10
|
+
This is **not** the same as E2B's ``autoPause``/``on_timeout``, which decides what
|
|
11
|
+
happens when the sandbox's *deadline* runs out. Idle pause has no deadline: the
|
|
12
|
+
sandbox keeps running for as long as it is used, and pauses once it is not.
|
|
13
|
+
|
|
14
|
+
What counts as use, so the clock restarts: any
|
|
15
|
+
request to any of the sandbox's ports except envd's ``GET /health``; an open live
|
|
16
|
+
view, terminal, SSH session or byte pipe; and any process envd started that is
|
|
17
|
+
still running. Not counted: CPU burned inside the guest by something envd did not
|
|
18
|
+
start, outbound traffic the sandbox makes itself, and API calls *about* the
|
|
19
|
+
sandbox. A sandbox busy on work it started by itself will still pause.
|
|
20
|
+
|
|
21
|
+
Resume with ``Sandbox.connect(sandbox_id)``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from urllib.parse import quote
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from e2b.exceptions import InvalidArgumentException
|
|
30
|
+
|
|
31
|
+
from anona_compute._http import get_json, put_json
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from e2b.connection_config import ConnectionConfig
|
|
35
|
+
|
|
36
|
+
#: Longest idle time the gateway accepts (store.MaxIdlePauseMinutes): one week.
|
|
37
|
+
MAX_IDLE_PAUSE_MINUTES = 10080
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class IdlePause:
|
|
41
|
+
"""``sandbox.idle_pause``: read and set this sandbox's idle-pause time."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, sandbox_id: str, config: "ConnectionConfig"):
|
|
44
|
+
self._sandbox_id = sandbox_id
|
|
45
|
+
self._config = config
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def _path(self) -> str:
|
|
49
|
+
return f"/v1/sandboxes/{quote(self._sandbox_id, safe='')}"
|
|
50
|
+
|
|
51
|
+
def get(self) -> int:
|
|
52
|
+
"""Minutes of idleness before this sandbox pauses; ``0`` when off."""
|
|
53
|
+
body = get_json(self._config, f"{self._path}/access") or {}
|
|
54
|
+
return int(body.get("idlePauseMinutes") or 0)
|
|
55
|
+
|
|
56
|
+
def set(self, minutes: int) -> int:
|
|
57
|
+
"""Pause this sandbox after ``minutes`` without use. ``0`` turns it off.
|
|
58
|
+
|
|
59
|
+
Returns the value the gateway recorded, which is what later reads will show.
|
|
60
|
+
"""
|
|
61
|
+
if not isinstance(minutes, int) or isinstance(minutes, bool):
|
|
62
|
+
raise InvalidArgumentException("minutes must be an int")
|
|
63
|
+
if minutes < 0 or minutes > MAX_IDLE_PAUSE_MINUTES:
|
|
64
|
+
raise InvalidArgumentException(
|
|
65
|
+
f"minutes must be 0 (off) to {MAX_IDLE_PAUSE_MINUTES}"
|
|
66
|
+
)
|
|
67
|
+
body = put_json(self._config, f"{self._path}/auto-pause", {"idleMinutes": minutes}) or {}
|
|
68
|
+
return int(body.get("idlePauseMinutes") or 0)
|
|
69
|
+
|
|
70
|
+
def off(self) -> int:
|
|
71
|
+
"""Stop pausing this sandbox when it is idle. Same as ``set(0)``."""
|
|
72
|
+
return self.set(0)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Live view helpers: mint viewer tokens and embed URLs, revoke viewers.
|
|
2
|
+
|
|
3
|
+
Gateway contract (plan 05, section 3.4):
|
|
4
|
+
|
|
5
|
+
- ``POST /v1/sandboxes/{id}/viewer-tokens`` with
|
|
6
|
+
``{"scope": "view"|"control", "channels": [...], "ttlSeconds": n}`` returns
|
|
7
|
+
``{"token", "expiresAt", "embedUrl"}``.
|
|
8
|
+
- ``POST /v1/sandboxes/{id}/viewer-tokens:revoke-all`` returns 200 ``{"epoch": n}``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
from e2b.exceptions import InvalidArgumentException
|
|
21
|
+
|
|
22
|
+
from anona_compute._http import post_json
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from e2b.connection_config import ConnectionConfig
|
|
26
|
+
|
|
27
|
+
SCOPES = ("view", "control")
|
|
28
|
+
CHANNELS = ("dom", "pty", "desktop", "thumb")
|
|
29
|
+
MIN_TTL = 30
|
|
30
|
+
MAX_TTL = 600
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ViewerToken:
|
|
35
|
+
"""A signed viewer token. ``repr`` never prints the token itself."""
|
|
36
|
+
|
|
37
|
+
token: str = field(repr=False)
|
|
38
|
+
expires_at: datetime
|
|
39
|
+
embed_url: str = field(repr=False)
|
|
40
|
+
|
|
41
|
+
def claims(self) -> Dict[str, Any]:
|
|
42
|
+
"""Decode the JWS payload without checking the signature (for inspection only)."""
|
|
43
|
+
parts = self.token.split(".")
|
|
44
|
+
if len(parts) != 3:
|
|
45
|
+
raise ValueError("viewer token is not a compact JWS")
|
|
46
|
+
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
47
|
+
return json.loads(base64.urlsafe_b64decode(payload))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_time(value: str) -> datetime:
|
|
51
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate(scope: str, channels: List[str], ttl: int) -> None:
|
|
55
|
+
if scope not in SCOPES:
|
|
56
|
+
raise InvalidArgumentException(f"scope must be one of {SCOPES}, got {scope!r}")
|
|
57
|
+
if not channels:
|
|
58
|
+
raise InvalidArgumentException("channels must not be empty")
|
|
59
|
+
unknown = [c for c in channels if c not in CHANNELS]
|
|
60
|
+
if unknown:
|
|
61
|
+
raise InvalidArgumentException(f"unknown channels {unknown}; allowed {CHANNELS}")
|
|
62
|
+
# The gateway decodes ttlSeconds into an int and answers 400 to 60.5, so a
|
|
63
|
+
# fraction is refused here, as the TypeScript SDK does (Number.isInteger).
|
|
64
|
+
if isinstance(ttl, bool) or not isinstance(ttl, int) or not MIN_TTL <= ttl <= MAX_TTL:
|
|
65
|
+
raise InvalidArgumentException(f"ttl must be a whole number of seconds from {MIN_TTL} to {MAX_TTL}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Live:
|
|
69
|
+
"""``sandbox.live``: viewer tokens for the live view of one sandbox."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, sandbox_id: str, config: "ConnectionConfig"):
|
|
72
|
+
self._sandbox_id = sandbox_id
|
|
73
|
+
self._config = config
|
|
74
|
+
|
|
75
|
+
def _path(self, suffix: str = "") -> str:
|
|
76
|
+
return f"/v1/sandboxes/{quote(self._sandbox_id, safe='')}/viewer-tokens{suffix}"
|
|
77
|
+
|
|
78
|
+
def token(
|
|
79
|
+
self,
|
|
80
|
+
scope: str = "view",
|
|
81
|
+
channels: Optional[Iterable[str]] = None,
|
|
82
|
+
ttl: int = MAX_TTL,
|
|
83
|
+
) -> ViewerToken:
|
|
84
|
+
"""Mint a viewer token (default: view-only DOM channel, 600 s)."""
|
|
85
|
+
chans = list(channels) if channels is not None else ["dom"]
|
|
86
|
+
_validate(scope, chans, ttl)
|
|
87
|
+
body = post_json(
|
|
88
|
+
self._config,
|
|
89
|
+
self._path(),
|
|
90
|
+
{"scope": scope, "channels": chans, "ttlSeconds": ttl},
|
|
91
|
+
)
|
|
92
|
+
if not body:
|
|
93
|
+
raise InvalidArgumentException("gateway returned an empty viewer token response")
|
|
94
|
+
return ViewerToken(
|
|
95
|
+
token=body["token"],
|
|
96
|
+
expires_at=_parse_time(body["expiresAt"]),
|
|
97
|
+
embed_url=body["embedUrl"],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def url(self, mode: str = "dom", scope: str = "view", ttl: int = MAX_TTL) -> str:
|
|
101
|
+
"""Mint a token for one channel and return its embed URL."""
|
|
102
|
+
return self.token(scope=scope, channels=[mode], ttl=ttl).embed_url
|
|
103
|
+
|
|
104
|
+
def revoke_all(self) -> None:
|
|
105
|
+
"""Close every open viewer of this sandbox and invalidate issued tokens."""
|
|
106
|
+
post_json(self._config, self._path(":revoke-all"))
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""``anona_compute.Sandbox``: the stock ``e2b.Sandbox`` plus Anona defaults and extras."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Literal, Unpack
|
|
7
|
+
|
|
8
|
+
import e2b
|
|
9
|
+
from e2b.connection_config import ApiParams
|
|
10
|
+
|
|
11
|
+
from anona_compute._config import DEFAULT_DOMAIN, compute_key
|
|
12
|
+
from anona_compute.idle import IdlePause
|
|
13
|
+
from anona_compute.live import Live
|
|
14
|
+
from anona_compute.ssh import Ssh
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SandboxSize = Literal["small", "medium", "large", "xl"]
|
|
18
|
+
SIZES: tuple[str, ...] = ("small", "medium", "large", "xl")
|
|
19
|
+
_SIZE_ALIASES = {"s": "small", "m": "medium", "l": "large", "xlarge": "xl", "x-large": "xl"}
|
|
20
|
+
|
|
21
|
+
# The E2B SDK cannot send the create body's ``size`` (an Anona extension), so
|
|
22
|
+
# the size rides in this metadata key; the gateway consumes it and never
|
|
23
|
+
# stores it as metadata.
|
|
24
|
+
SIZE_METADATA_KEY = "anona.size"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def size_metadata(size: str, metadata: dict[str, str] | None = None) -> dict[str, str]:
|
|
28
|
+
"""Return ``metadata`` with the sandbox size set (small, medium, large or xl)."""
|
|
29
|
+
name = _SIZE_ALIASES.get(size.strip().lower(), size.strip().lower())
|
|
30
|
+
if name not in SIZES:
|
|
31
|
+
raise ValueError(f"unknown sandbox size {size!r}: use one of {', '.join(SIZES)}")
|
|
32
|
+
out = dict(metadata or {})
|
|
33
|
+
out[SIZE_METADATA_KEY] = name
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def anona_defaults() -> ApiParams:
|
|
38
|
+
"""API params that point the SDK at Anona unless E2B_* variables say otherwise."""
|
|
39
|
+
params: ApiParams = {}
|
|
40
|
+
if not os.getenv("E2B_DOMAIN"):
|
|
41
|
+
params["domain"] = os.getenv("ANONA_DOMAIN") or DEFAULT_DOMAIN
|
|
42
|
+
key = compute_key(os.getenv("ANONA_API_KEY"))
|
|
43
|
+
if not os.getenv("E2B_API_KEY") and key:
|
|
44
|
+
params["api_key"] = key
|
|
45
|
+
if not os.getenv("E2B_API_URL") and os.getenv("ANONA_API_URL"):
|
|
46
|
+
params["api_url"] = os.environ["ANONA_API_URL"]
|
|
47
|
+
return params
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Sandbox(e2b.Sandbox):
|
|
51
|
+
"""Every ``e2b.Sandbox`` method works unchanged; ``live``, ``ssh`` and ``idle_pause`` are added."""
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def _resolve_api_params(cls, **opts: Unpack[ApiParams]) -> ApiParams:
|
|
55
|
+
merged = dict(anona_defaults())
|
|
56
|
+
resolved = super()._resolve_api_params(**opts)
|
|
57
|
+
merged.update({k: v for k, v in resolved.items() if v is not None})
|
|
58
|
+
return merged # type: ignore[return-value]
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def create(cls, template: str | None = None, *args: Any, size: str | None = None, **kwargs: Any): # type: ignore[override]
|
|
62
|
+
"""``e2b.Sandbox.create`` plus ``size``: small, medium, large or xl.
|
|
63
|
+
|
|
64
|
+
A size decides the sandbox's vCPU, memory and disk; without one the
|
|
65
|
+
sandbox runs at its template's own size. A size below the template's
|
|
66
|
+
minimum, or not in your plan, is refused with the sizes that would work.
|
|
67
|
+
"""
|
|
68
|
+
if size is not None:
|
|
69
|
+
if len(args) >= 2: # timeout, metadata given positionally
|
|
70
|
+
lst = list(args)
|
|
71
|
+
lst[1] = size_metadata(size, lst[1])
|
|
72
|
+
args = tuple(lst)
|
|
73
|
+
else:
|
|
74
|
+
kwargs["metadata"] = size_metadata(size, kwargs.get("metadata"))
|
|
75
|
+
return super().create(template, *args, **kwargs)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def live(self) -> Live:
|
|
79
|
+
"""Live view: ``token()``, ``url()``, ``revoke_all()``."""
|
|
80
|
+
return Live(self.sandbox_id, self.connection_config)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def ssh(self) -> Ssh:
|
|
84
|
+
"""SSH: ``certificate(public_key)`` and ``config()``."""
|
|
85
|
+
return Ssh(self.sandbox_id, self.connection_config)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def idle_pause(self) -> IdlePause:
|
|
89
|
+
"""Idle pause: ``get()``, ``set(minutes)``, ``off()``.
|
|
90
|
+
|
|
91
|
+
Pauses the sandbox once nothing has used it for that long. Different from
|
|
92
|
+
E2B's ``on_timeout``, which acts when the deadline runs out.
|
|
93
|
+
"""
|
|
94
|
+
return IdlePause(self.sandbox_id, self.connection_config)
|