solari-runloop-compat 0.1.0__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.
- solari_runloop/__init__.py +42 -0
- solari_runloop/_branding.py +10 -0
- solari_runloop/_client.py +107 -0
- solari_runloop/_config.py +57 -0
- solari_runloop/_context.py +91 -0
- solari_runloop/_dockerfile.py +348 -0
- solari_runloop/_exceptions.py +122 -0
- solari_runloop/_gateway.py +1048 -0
- solari_runloop/_guest/solari-run.sh +14 -0
- solari_runloop/_guest/solari_extract.py +101 -0
- solari_runloop/_guest/solari_sizecheck.py +14 -0
- solari_runloop/_loop.py +54 -0
- solari_runloop/_mapping.py +322 -0
- solari_runloop/_models.py +13 -0
- solari_runloop/_resources.py +416 -0
- solari_runloop/_sync.py +76 -0
- solari_runloop/lib/__init__.py +0 -0
- solari_runloop/lib/polling.py +118 -0
- solari_runloop/pagination.py +66 -0
- solari_runloop/sdk.py +594 -0
- solari_runloop/types/__init__.py +17 -0
- solari_runloop/types/blueprint_build_log.py +16 -0
- solari_runloop/types/blueprint_build_logs_list_view.py +16 -0
- solari_runloop/types/blueprint_build_parameters.py +123 -0
- solari_runloop/types/blueprint_view.py +97 -0
- solari_runloop/types/devbox_async_execution_detail_view.py +46 -0
- solari_runloop/types/devbox_execution_detail_view.py +24 -0
- solari_runloop/types/devbox_snapshot_list_view.py +17 -0
- solari_runloop/types/devbox_snapshot_view.py +33 -0
- solari_runloop/types/devbox_view.py +176 -0
- solari_runloop/types/devboxes/__init__.py +0 -0
- solari_runloop/types/devboxes/devbox_logs_list_view.py +39 -0
- solari_runloop/types/secret_view.py +23 -0
- solari_runloop/types/shared/__init__.py +0 -0
- solari_runloop/types/shared/after_idle.py +15 -0
- solari_runloop/types/shared/code_mount_parameters.py +30 -0
- solari_runloop/types/shared/launch_parameters.py +104 -0
- solari_runloop/types/shared/lifecycle_configuration.py +38 -0
- solari_runloop/types/shared/lifecycle_hooks.py +28 -0
- solari_runloop/types/shared/resume_triggers.py +17 -0
- solari_runloop/types/tunnel_view.py +47 -0
- solari_runloop_compat-0.1.0.dist-info/METADATA +47 -0
- solari_runloop_compat-0.1.0.dist-info/RECORD +45 -0
- solari_runloop_compat-0.1.0.dist-info/WHEEL +4 -0
- solari_runloop_compat-0.1.0.dist-info/licenses/NOTICE +25 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Runloop-compatible interface over the Solari sandbox gateway.
|
|
2
|
+
|
|
3
|
+
Not affiliated with Runloop. See COMPAT.md for exactly which parts of the Runloop SDK are supported.
|
|
4
|
+
Change ``from runloop_api_client import RunloopSDK`` to ``from solari_runloop import RunloopSDK``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from . import types
|
|
10
|
+
from ._branding import TARGET_RUNLOOP_VERSION
|
|
11
|
+
from ._client import AsyncRunloop, Runloop
|
|
12
|
+
from ._exceptions import (
|
|
13
|
+
APIError,
|
|
14
|
+
APIStatusError,
|
|
15
|
+
ConflictError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
RunloopError,
|
|
19
|
+
BadRequestError,
|
|
20
|
+
APITimeoutError,
|
|
21
|
+
APIConnectionError,
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
InternalServerError,
|
|
24
|
+
PermissionDeniedError,
|
|
25
|
+
SolariCompatUnsupported,
|
|
26
|
+
UnprocessableEntityError,
|
|
27
|
+
APIResponseValidationError,
|
|
28
|
+
)
|
|
29
|
+
from .lib.polling import PollingConfig, PollingTimeout
|
|
30
|
+
from .sdk import AsyncRunloopSDK, RunloopSDK
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
Client = Runloop
|
|
34
|
+
AsyncClient = AsyncRunloop
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Runloop", "AsyncRunloop", "RunloopSDK", "AsyncRunloopSDK", "Client", "AsyncClient",
|
|
38
|
+
"RunloopError", "APIError", "APIStatusError", "APIConnectionError", "APITimeoutError",
|
|
39
|
+
"APIResponseValidationError", "BadRequestError", "AuthenticationError", "PermissionDeniedError",
|
|
40
|
+
"NotFoundError", "ConflictError", "UnprocessableEntityError", "RateLimitError", "InternalServerError",
|
|
41
|
+
"SolariCompatUnsupported", "PollingConfig", "PollingTimeout", "types", "TARGET_RUNLOOP_VERSION",
|
|
42
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Every name that might change after the naming/legal review lives here."""
|
|
2
|
+
|
|
3
|
+
PACKAGE_NAME = "solari-runloop-compat"
|
|
4
|
+
MODULE_NAME = "solari_runloop"
|
|
5
|
+
#: Runloop SDK release whose public surface this layer mirrors (see COMPAT.md).
|
|
6
|
+
TARGET_RUNLOOP_VERSION = "2.0.0"
|
|
7
|
+
DEFAULT_BASE_URL = "https://api.getsolari.com"
|
|
8
|
+
KEY_ENV_VARS = ("SOLARI_API_KEY", "RUNLOOP_API_KEY")
|
|
9
|
+
BASE_URL_ENV_VARS = ("SOLARI_BASE_URL", "RUNLOOP_BASE_URL")
|
|
10
|
+
KEY_PREFIX = "slr_"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""``Runloop`` / ``AsyncRunloop``: the REST-style client (``client.devboxes.*``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
from . import _config
|
|
8
|
+
from ._gateway import Gateway
|
|
9
|
+
from ._loop import LoopThread
|
|
10
|
+
from ._resources import AsyncBlueprints, AsyncDevboxes, AsyncSecrets, _Unsupported
|
|
11
|
+
from ._sync import SyncMirror
|
|
12
|
+
|
|
13
|
+
DEFAULT_MAX_RETRIES = 5
|
|
14
|
+
|
|
15
|
+
_UNSUPPORTED_RESOURCES = {
|
|
16
|
+
"objects": "Storage objects have no Solari equivalent yet.",
|
|
17
|
+
"agents": "Agents are a Runloop platform feature.",
|
|
18
|
+
"axons": "Axons are a Runloop platform feature.",
|
|
19
|
+
"network_policies": "Network policies have no Solari equivalent.",
|
|
20
|
+
"gateway_configs": "Agent gateways are a Runloop platform feature.",
|
|
21
|
+
"mcp_configs": "MCP configs are a Runloop platform feature.",
|
|
22
|
+
"accounts": "Accounts are a Runloop platform feature.",
|
|
23
|
+
"apikeys": "API keys are managed in the Solari console.",
|
|
24
|
+
"restricted_keys": "Restricted keys are a Runloop platform feature.",
|
|
25
|
+
"pty": "PTY tunnels are not exposed through the compat layer.",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncRunloop:
|
|
30
|
+
"""Async REST client. Accepts Runloop's constructor arguments; ``timeout``/``default_headers``/
|
|
31
|
+
``http_client`` are accepted for source compatibility and not used."""
|
|
32
|
+
|
|
33
|
+
_solari_mirror = False
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
bearer_token: Optional[str] = None,
|
|
39
|
+
base_url: Optional[str] = None,
|
|
40
|
+
timeout: Any = None,
|
|
41
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
42
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
43
|
+
default_query: Optional[Mapping[str, object]] = None,
|
|
44
|
+
http_client: Any = None,
|
|
45
|
+
**_ignored: Any,
|
|
46
|
+
) -> None:
|
|
47
|
+
token, url = _config.resolve(bearer_token, base_url)
|
|
48
|
+
self.bearer_token = token
|
|
49
|
+
self.base_url = url
|
|
50
|
+
self.max_retries = max_retries
|
|
51
|
+
self._gw = Gateway(token=token, base_url=url, max_retries=max_retries)
|
|
52
|
+
self.devboxes = AsyncDevboxes(self._gw)
|
|
53
|
+
self.blueprints = AsyncBlueprints(self._gw)
|
|
54
|
+
self.secrets = AsyncSecrets(self._gw)
|
|
55
|
+
for name, hint in _UNSUPPORTED_RESOURCES.items():
|
|
56
|
+
setattr(self, name, _Unsupported(name, hint))
|
|
57
|
+
|
|
58
|
+
def with_options(self, **_: Any) -> "AsyncRunloop":
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
async def close(self) -> None:
|
|
62
|
+
await self._gw.aclose()
|
|
63
|
+
|
|
64
|
+
async def __aenter__(self) -> "AsyncRunloop":
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def __aexit__(self, *_exc: Any) -> None:
|
|
68
|
+
await self.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Runloop:
|
|
72
|
+
"""Sync REST client: the async client driven from a background event loop (see ``_loop.py``)."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
75
|
+
self._attach(AsyncRunloop(**kwargs), LoopThread())
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def _wrapping(cls, inner: AsyncRunloop, loop: LoopThread) -> "Runloop":
|
|
79
|
+
"""A sync view of an existing async client (used by ``RunloopSDK`` so both share one loop)."""
|
|
80
|
+
self = cls.__new__(cls)
|
|
81
|
+
self._attach(inner, loop)
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def _attach(self, inner: AsyncRunloop, loop: LoopThread) -> None:
|
|
85
|
+
self._loop = loop
|
|
86
|
+
self._inner = inner
|
|
87
|
+
self.bearer_token = self._inner.bearer_token
|
|
88
|
+
self.base_url = self._inner.base_url
|
|
89
|
+
self.max_retries = self._inner.max_retries
|
|
90
|
+
self.devboxes = SyncMirror(self._inner.devboxes, self._loop)
|
|
91
|
+
self.blueprints = SyncMirror(self._inner.blueprints, self._loop)
|
|
92
|
+
self.secrets = SyncMirror(self._inner.secrets, self._loop)
|
|
93
|
+
for name in _UNSUPPORTED_RESOURCES:
|
|
94
|
+
setattr(self, name, getattr(self._inner, name))
|
|
95
|
+
|
|
96
|
+
def with_options(self, **_: Any) -> "Runloop":
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
self._loop.run(self._inner.close())
|
|
101
|
+
self._loop.close()
|
|
102
|
+
|
|
103
|
+
def __enter__(self) -> "Runloop":
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
107
|
+
self.close()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Credential and base-URL resolution: explicit argument, then SOLARI_*, then the RUNLOOP_* names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
from typing import Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from ._branding import DEFAULT_BASE_URL, KEY_PREFIX
|
|
11
|
+
from ._exceptions import RunloopError
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("solari_runloop")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _first_env(names: Tuple[str, ...]) -> Optional[str]: # base URLs only; keys are handled in resolve()
|
|
17
|
+
for n in names:
|
|
18
|
+
v = os.environ.get(n)
|
|
19
|
+
if v:
|
|
20
|
+
return v
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_solari_host(url: str) -> bool:
|
|
25
|
+
host = (urlparse(url).hostname or "").lower()
|
|
26
|
+
return host == "getsolari.com" or host.endswith(".getsolari.com") or host in ("localhost", "127.0.0.1", "::1")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _env_base_url() -> Optional[str]:
|
|
30
|
+
"""``SOLARI_BASE_URL`` is taken as given. ``RUNLOOP_BASE_URL`` (a Runloop variable, commonly ``https://api.runloop.ai``)
|
|
31
|
+
is only honoured for getsolari.com or loopback hosts: otherwise the Solari key would be sent to a third party."""
|
|
32
|
+
v = os.environ.get("SOLARI_BASE_URL")
|
|
33
|
+
if v:
|
|
34
|
+
return v
|
|
35
|
+
v = os.environ.get("RUNLOOP_BASE_URL")
|
|
36
|
+
if v and not _is_solari_host(v):
|
|
37
|
+
log.warning("Ignoring RUNLOOP_BASE_URL: its host is not a Solari host, and the Solari API key must not be sent there.")
|
|
38
|
+
return None
|
|
39
|
+
return v
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve(bearer_token: Optional[str], base_url: Optional[str]) -> Tuple[str, str]:
|
|
43
|
+
"""Key and base URL. A key that does not start with ``slr_`` is never used or sent: it is probably a Runloop key
|
|
44
|
+
(``Runloop(bearer_token=os.environ["RUNLOOP_API_KEY"])`` is a common pattern) and sending it would leak a third-party
|
|
45
|
+
secret. Sources are tried in order (argument, ``SOLARI_API_KEY``, ``RUNLOOP_API_KEY``); a non-``slr_`` one is skipped with a
|
|
46
|
+
warning that never prints the key, and if no ``slr_`` key remains this raises."""
|
|
47
|
+
for source, value in (("the bearer_token argument", bearer_token), ("SOLARI_API_KEY", os.environ.get("SOLARI_API_KEY")), ("RUNLOOP_API_KEY", os.environ.get("RUNLOOP_API_KEY"))):
|
|
48
|
+
if not value:
|
|
49
|
+
continue
|
|
50
|
+
if value.startswith(KEY_PREFIX):
|
|
51
|
+
url = str(base_url) if base_url else (_env_base_url() or DEFAULT_BASE_URL)
|
|
52
|
+
return value, url.rstrip("/")
|
|
53
|
+
log.warning("Ignoring %s: it does not start with %r, so it is not a Solari key and will not be sent to Solari.", source, KEY_PREFIX)
|
|
54
|
+
raise RunloopError(
|
|
55
|
+
"No Solari API key: pass bearer_token or set SOLARI_API_KEY to a key starting with 'slr_' "
|
|
56
|
+
"(keys from other services, including a Runloop RUNLOOP_API_KEY, are never sent to Solari)"
|
|
57
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Build contexts for ``COPY``/``ADD``.
|
|
2
|
+
|
|
3
|
+
Runloop uploads a context directory as a *storage object*. Solari has no object store yet (GATEWAY-ENDPOINTS.md section 3), so until it does the
|
|
4
|
+
context is kept in this process as a deterministic ``.tar.gz`` and embedded in the build recipe (``POST /templates`` bodies are capped at 1 MiB, so
|
|
5
|
+
the context is capped at ~700 KB). When objects land, :class:`LocalContext` is replaced by a real upload and the recipe carries an object id instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import hashlib
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
import tarfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
from . import _exceptions as ex
|
|
20
|
+
|
|
21
|
+
MAX_CONTEXT_BYTES = 700_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LocalContext:
|
|
26
|
+
id: str
|
|
27
|
+
tar_gz: bytes
|
|
28
|
+
name: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ignored(rel: str, patterns: List[str]) -> bool:
|
|
32
|
+
ignored = False
|
|
33
|
+
for pat in patterns:
|
|
34
|
+
neg = pat.startswith("!")
|
|
35
|
+
pat = pat.lstrip("!").strip("/")
|
|
36
|
+
if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(rel, pat + "/*") or any(fnmatch.fnmatch(part, pat) for part in rel.split("/")):
|
|
37
|
+
ignored = not neg
|
|
38
|
+
return ignored
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _make(entries: List[tuple], name: Optional[str]) -> LocalContext:
|
|
42
|
+
buf = io.BytesIO()
|
|
43
|
+
with tarfile.open(fileobj=buf, mode="w:gz", compresslevel=9) as tf:
|
|
44
|
+
for arc, path in entries:
|
|
45
|
+
info = tf.gettarinfo(str(path), arcname=arc)
|
|
46
|
+
info.mtime, info.uid, info.gid, info.uname, info.gname = 0, 0, 0, "", ""
|
|
47
|
+
if info.isreg():
|
|
48
|
+
with open(path, "rb") as fh:
|
|
49
|
+
tf.addfile(info, fh)
|
|
50
|
+
else:
|
|
51
|
+
tf.addfile(info)
|
|
52
|
+
data = buf.getvalue()
|
|
53
|
+
if len(data) > MAX_CONTEXT_BYTES:
|
|
54
|
+
raise ex.SolariCompatUnsupported(
|
|
55
|
+
"Build contexts over ~700 KB compressed",
|
|
56
|
+
f"This context is {len(data)} bytes. Larger contexts need the gateway's storage objects; fetch big assets with RUN instead.",
|
|
57
|
+
)
|
|
58
|
+
return LocalContext(id="ctx_" + hashlib.sha256(data).hexdigest()[:16], tar_gz=data, name=name)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def from_dir(path: str, *, name: Optional[str] = None, ignore: Optional[List[str]] = None) -> LocalContext:
|
|
62
|
+
root = Path(path)
|
|
63
|
+
patterns = list(ignore or [])
|
|
64
|
+
di = root / ".dockerignore"
|
|
65
|
+
if di.exists():
|
|
66
|
+
patterns += [ln.strip() for ln in di.read_text().splitlines() if ln.strip() and not ln.startswith("#")]
|
|
67
|
+
entries = []
|
|
68
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
69
|
+
dirnames.sort()
|
|
70
|
+
rel_dir = os.path.relpath(dirpath, root)
|
|
71
|
+
for n in sorted(dirnames + filenames):
|
|
72
|
+
full = Path(dirpath) / n
|
|
73
|
+
rel = n if rel_dir == "." else f"{rel_dir}/{n}"
|
|
74
|
+
if _ignored(rel, patterns):
|
|
75
|
+
if n in dirnames:
|
|
76
|
+
dirnames.remove(n)
|
|
77
|
+
continue
|
|
78
|
+
entries.append((rel, full))
|
|
79
|
+
return _make(entries, name)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def from_bytes(data: bytes, name: str = "file") -> LocalContext:
|
|
83
|
+
buf = io.BytesIO()
|
|
84
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
|
85
|
+
info = tarfile.TarInfo(name)
|
|
86
|
+
info.size, info.mode = len(data), 0o644
|
|
87
|
+
tf.addfile(info, io.BytesIO(data))
|
|
88
|
+
b = buf.getvalue()
|
|
89
|
+
if len(b) > MAX_CONTEXT_BYTES:
|
|
90
|
+
raise ex.SolariCompatUnsupported("Build contexts over ~700 KB compressed", "Needs the gateway's storage objects.")
|
|
91
|
+
return LocalContext(id="ctx_" + hashlib.sha256(b).hexdigest()[:16], tar_gz=b, name=name)
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Dockerfile (a practical subset) -> Solari's ``CompiledImage`` build recipe.
|
|
2
|
+
|
|
3
|
+
Solari builds a template by running ordered steps inside a VM cloned from a golden base and snapshotting the
|
|
4
|
+
result, so a Dockerfile maps onto that recipe rather than onto an OCI image build. What is accepted is exactly what
|
|
5
|
+
that model can do faithfully; everything else raises :class:`SolariCompatUnsupported` naming the instruction
|
|
6
|
+
(BLUEPRINTS.md describes the full design and the path to arbitrary base images).
|
|
7
|
+
|
|
8
|
+
Supported: ``FROM`` (one stage; ubuntu/debian-family or a Solari base/template), ``RUN``, ``ENV``, ``ARG`` (defaults and
|
|
9
|
+
``--build-arg``), ``WORKDIR``, ``ENTRYPOINT``, ``CMD``, ``LABEL``/``EXPOSE``/``MAINTAINER`` (ignored), ``SHELL`` (bash only).
|
|
10
|
+
Not yet: ``COPY``/``ADD`` (need build-context objects), multi-stage, ``USER``, ``VOLUME``, ``HEALTHCHECK``, ``ONBUILD``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import json
|
|
17
|
+
import posixpath
|
|
18
|
+
import re
|
|
19
|
+
import logging
|
|
20
|
+
import shlex
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
from . import _exceptions as ex
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("solari_runloop")
|
|
27
|
+
|
|
28
|
+
#: Image repositories that are Debian/Ubuntu userlands, which is what Solari's ``base`` golden is.
|
|
29
|
+
DEBIAN_FAMILY = {"ubuntu", "debian"}
|
|
30
|
+
#: Names that select a Solari golden base directly.
|
|
31
|
+
SOLARI_BASES = {"base", "workstation"}
|
|
32
|
+
|
|
33
|
+
#: Pinned build tool for image import (BLUEPRINTS.md, level 3a). The build FAILS if the checksum does not match.
|
|
34
|
+
CRANE_VERSION = "v0.20.3"
|
|
35
|
+
CRANE_SHA256 = "36c67a932f489b3f2724b64af90b599a8ef2aa7b004872597373c0ad694dc059"
|
|
36
|
+
CRANE_URL = f"https://github.com/google/go-containerregistry/releases/download/{CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz"
|
|
37
|
+
ROOT_DIR = "/srv/root"
|
|
38
|
+
CONTEXT_DIR = "/tmp/solari-context"
|
|
39
|
+
#: Registries the import accepts (public pulls only; there are no credentials in the build).
|
|
40
|
+
REGISTRY_ALLOWLIST = ("docker.io", "index.docker.io", "registry-1.docker.io", "ghcr.io", "public.ecr.aws", "gcr.io", "quay.io", "mcr.microsoft.com")
|
|
41
|
+
#: ``build_args`` switch that forces image import even for ubuntu/debian images (default: those use the native base).
|
|
42
|
+
IMPORT_ARG = "SOLARI_IMPORT"
|
|
43
|
+
|
|
44
|
+
_GUEST = Path(__file__).parent / "_guest"
|
|
45
|
+
#: Runs a command inside the imported root (ENV and WORKDIR applied); installed into the builder, so into every
|
|
46
|
+
#: devbox created from the template. The compat layer's exec wrapper calls it too.
|
|
47
|
+
SOLARI_RUN = (_GUEST / "solari-run.sh").read_text()
|
|
48
|
+
#: Validating tar extractor (see its docstring for the guards).
|
|
49
|
+
SOLARI_EXTRACT = (_GUEST / "solari_extract.py").read_text()
|
|
50
|
+
SOLARI_SIZECHECK = (_GUEST / "solari_sizecheck.py").read_text()
|
|
51
|
+
#: Caps for one import: compressed layers (checked from the manifest before downloading), extracted bytes, wall clock.
|
|
52
|
+
MAX_COMPRESSED_BYTES = 2 << 30
|
|
53
|
+
MAX_EXTRACTED_BYTES = 4 << 30
|
|
54
|
+
IMPORT_TIMEOUT_S = 900
|
|
55
|
+
CRANE_BIN_SHA256 = "675f3b2f1696c1f6bc55b1ef535163364119776999f3d1471e4558ed35bab548"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def logical_lines(text: str) -> List[Tuple[str, str]]:
|
|
59
|
+
"""Join ``\\`` continuations, drop comments/blank lines, split into ``(INSTRUCTION, rest)``."""
|
|
60
|
+
out: List[Tuple[str, str]] = []
|
|
61
|
+
buf = ""
|
|
62
|
+
for raw in text.splitlines():
|
|
63
|
+
line = raw.rstrip()
|
|
64
|
+
if not buf and (not line.strip() or line.lstrip().startswith("#")):
|
|
65
|
+
continue
|
|
66
|
+
if buf and line.lstrip().startswith("#"):
|
|
67
|
+
continue
|
|
68
|
+
if line.endswith("\\"):
|
|
69
|
+
buf += line[:-1] + " "
|
|
70
|
+
continue
|
|
71
|
+
buf += line
|
|
72
|
+
stripped = buf.strip()
|
|
73
|
+
buf = ""
|
|
74
|
+
if stripped:
|
|
75
|
+
instr, _, rest = stripped.partition(" ")
|
|
76
|
+
out.append((instr.upper(), rest.strip()))
|
|
77
|
+
if buf.strip():
|
|
78
|
+
instr, _, rest = buf.strip().partition(" ")
|
|
79
|
+
out.append((instr.upper(), rest.strip()))
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Docker's default PATH, so `ENV PATH=/opt/x/bin:$PATH` expands to a usable value (an unknown $VAR is "").
|
|
84
|
+
DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _subst(s: str, args: Dict[str, str]) -> str:
|
|
88
|
+
def rep(m: "re.Match[str]") -> str:
|
|
89
|
+
name = m.group(1) or m.group(2)
|
|
90
|
+
return args.get(name, "")
|
|
91
|
+
|
|
92
|
+
return re.sub(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))", rep, s)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _json_or_shell(rest: str) -> List[str]:
|
|
96
|
+
rest = rest.strip()
|
|
97
|
+
if rest.startswith("["):
|
|
98
|
+
try:
|
|
99
|
+
v = json.loads(rest)
|
|
100
|
+
if isinstance(v, list) and all(isinstance(x, str) for x in v):
|
|
101
|
+
return v
|
|
102
|
+
except ValueError:
|
|
103
|
+
pass
|
|
104
|
+
return ["/bin/sh", "-c", rest]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_env(rest: str) -> List[Tuple[str, str]]:
|
|
108
|
+
# ``ENV k=v k2="v 2"`` or the legacy ``ENV k v``.
|
|
109
|
+
if "=" not in rest.split(None, 1)[0]:
|
|
110
|
+
k, _, v = rest.partition(" ")
|
|
111
|
+
return [(k, v.strip().strip('"'))]
|
|
112
|
+
return [(t.partition("=")[0], t.partition("=")[2]) for t in shlex.split(rest)]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _registry_of(image: str) -> str:
|
|
116
|
+
first = image.split("/")[0]
|
|
117
|
+
return first if ("." in first or ":" in first) and "/" in image else "docker.io"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _resolve_from(rest: str, args: Dict[str, str]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
|
121
|
+
"""-> ``(base, from_template, import_image)`` for the CompiledImage."""
|
|
122
|
+
tokens = [t for t in rest.split() if not t.startswith("--")]
|
|
123
|
+
if "AS" in [t.upper() for t in tokens[1:]]:
|
|
124
|
+
raise ex.SolariCompatUnsupported("Multi-stage Dockerfile (FROM ... AS)", "Single-stage builds only.")
|
|
125
|
+
image = _subst(tokens[0], args)
|
|
126
|
+
repo = image.split("@", 1)[0]
|
|
127
|
+
if ":" in repo.split("/")[-1]:
|
|
128
|
+
repo = repo.rsplit(":", 1)[0]
|
|
129
|
+
if image == "scratch":
|
|
130
|
+
raise ex.SolariCompatUnsupported("FROM scratch")
|
|
131
|
+
parts = repo.split("/")
|
|
132
|
+
if len(parts) > 1 and ("." in parts[0] or ":" in parts[0] or parts[0] == "localhost"):
|
|
133
|
+
parts = parts[1:] # registry host
|
|
134
|
+
if parts[:1] == ["library"]:
|
|
135
|
+
parts = parts[1:]
|
|
136
|
+
name = "/".join(parts)
|
|
137
|
+
force = args.get(IMPORT_ARG) not in (None, "", "0", "false")
|
|
138
|
+
if name in SOLARI_BASES:
|
|
139
|
+
return name, None, None
|
|
140
|
+
if name in DEBIAN_FAMILY and not force:
|
|
141
|
+
return "base", None, None
|
|
142
|
+
registry = _registry_of(image)
|
|
143
|
+
if registry not in REGISTRY_ALLOWLIST:
|
|
144
|
+
raise ex.SolariCompatUnsupported(
|
|
145
|
+
f"FROM {image}",
|
|
146
|
+
f"Image import only pulls public images from {', '.join(REGISTRY_ALLOWLIST)} (no registry credentials in builds).",
|
|
147
|
+
)
|
|
148
|
+
return None, None, image
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def compile_dockerfile(
|
|
152
|
+
dockerfile: Optional[str],
|
|
153
|
+
*,
|
|
154
|
+
system_setup_commands: Optional[List[str]] = None,
|
|
155
|
+
file_mounts: Optional[Dict[str, str]] = None,
|
|
156
|
+
base_blueprint_name: Optional[str] = None,
|
|
157
|
+
build_args: Optional[Dict[str, str]] = None,
|
|
158
|
+
context: Optional[bytes] = None,
|
|
159
|
+
) -> Dict[str, Any]:
|
|
160
|
+
"""Runloop blueprint-create inputs -> a ``CompiledImage`` dict for ``POST /templates``.
|
|
161
|
+
|
|
162
|
+
``context`` is a ``.tar.gz`` build context for ``COPY``/``ADD`` (see ``_context.py``)."""
|
|
163
|
+
steps: List[Dict[str, Any]] = []
|
|
164
|
+
base: Optional[str] = None
|
|
165
|
+
from_template: Optional[str] = base_blueprint_name
|
|
166
|
+
args: Dict[str, str] = {"PATH": DEFAULT_PATH, **(build_args or {})} # ARG + ENV, for $VAR substitution
|
|
167
|
+
arg_only: Dict[str, str] = dict(build_args or {}) # ARGs, exported into each RUN
|
|
168
|
+
seen_from = False
|
|
169
|
+
import_image: Optional[str] = None
|
|
170
|
+
workdir = "/"
|
|
171
|
+
context_loaded = False
|
|
172
|
+
|
|
173
|
+
for instr, rest in logical_lines(dockerfile or ""):
|
|
174
|
+
if instr == "FROM":
|
|
175
|
+
if seen_from:
|
|
176
|
+
raise ex.SolariCompatUnsupported("Multi-stage Dockerfile (second FROM)", "Single-stage builds only.")
|
|
177
|
+
seen_from = True
|
|
178
|
+
fb, ft, img = _resolve_from(rest, args)
|
|
179
|
+
if base_blueprint_name:
|
|
180
|
+
log.warning("base_blueprint_name %r overrides the Dockerfile FROM", base_blueprint_name)
|
|
181
|
+
else:
|
|
182
|
+
base, from_template, import_image = fb, ft, img
|
|
183
|
+
if import_image:
|
|
184
|
+
log.warning(
|
|
185
|
+
"FROM %s is imported into a chroot inside the builder VM (BLUEPRINTS.md level 3a): needs a shell in the image, "
|
|
186
|
+
"~2 GB free disk, ENTRYPOINT/CMD are recorded but not started, public registries only.",
|
|
187
|
+
import_image,
|
|
188
|
+
)
|
|
189
|
+
steps.extend(import_steps(import_image))
|
|
190
|
+
elif instr == "ARG":
|
|
191
|
+
k, eq, v = rest.partition("=")
|
|
192
|
+
k = k.strip()
|
|
193
|
+
args.setdefault(k, v.strip().strip('"') if eq else "")
|
|
194
|
+
arg_only[k] = args[k]
|
|
195
|
+
elif instr == "ENV":
|
|
196
|
+
for k, v in _parse_env(_subst(rest, args)):
|
|
197
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k):
|
|
198
|
+
raise ex.SolariCompatUnsupported(f"ENV name {k!r}", "Environment variable names must match [A-Za-z_][A-Za-z0-9_]*.")
|
|
199
|
+
args[k] = v
|
|
200
|
+
steps.append({"op": "env", "key": k, "value": v})
|
|
201
|
+
if import_image:
|
|
202
|
+
steps.append(_run(f"printf 'export %s=%s\\n' {shlex.quote(k)} {shlex.quote(shlex.quote(v))} >> /etc/solari-env"))
|
|
203
|
+
elif instr == "RUN":
|
|
204
|
+
cmd = " ".join(shlex.quote(a) for a in json.loads(rest)) if rest.startswith("[") else _subst_run(rest, arg_only)
|
|
205
|
+
steps.append(_run("solari-run " + shlex.quote(cmd)) if import_image else _run(cmd))
|
|
206
|
+
elif instr == "WORKDIR":
|
|
207
|
+
wd = _subst(rest, args)
|
|
208
|
+
wd = wd if wd.startswith("/") else posixpath.normpath(posixpath.join(workdir, wd))
|
|
209
|
+
workdir = wd
|
|
210
|
+
steps.append({"op": "workdir", "dir": wd})
|
|
211
|
+
if import_image:
|
|
212
|
+
steps.append(_run(f"echo {shlex.quote(wd)} > /etc/solari-workdir && solari-run {shlex.quote('mkdir -p ' + shlex.quote(wd))}"))
|
|
213
|
+
elif instr == "ENTRYPOINT":
|
|
214
|
+
cmdv = _json_or_shell(_subst(rest, args))
|
|
215
|
+
steps.append({"op": "entrypoint", "command": cmdv})
|
|
216
|
+
if import_image:
|
|
217
|
+
steps.append(_run(f"echo {shlex.quote(json.dumps(cmdv))} > /etc/solari-entrypoint"))
|
|
218
|
+
elif instr == "CMD":
|
|
219
|
+
cmdv = _json_or_shell(_subst(rest, args))
|
|
220
|
+
steps.append({"op": "cmd", "command": cmdv})
|
|
221
|
+
if import_image:
|
|
222
|
+
steps.append(_run(f"echo {shlex.quote(json.dumps(cmdv))} > /etc/solari-cmd"))
|
|
223
|
+
elif instr in ("LABEL", "EXPOSE", "MAINTAINER", "STOPSIGNAL"):
|
|
224
|
+
continue
|
|
225
|
+
elif instr == "SHELL":
|
|
226
|
+
if "bash" not in rest and "sh" not in rest:
|
|
227
|
+
raise ex.SolariCompatUnsupported("SHELL " + rest)
|
|
228
|
+
elif instr in ("COPY", "ADD"):
|
|
229
|
+
if context is None:
|
|
230
|
+
raise ex.SolariCompatUnsupported(instr, "No build_context was given. Upload one with sdk.storage_object.upload_from_dir(...) and pass build_context=obj.as_build_context().")
|
|
231
|
+
if not context_loaded:
|
|
232
|
+
steps.append(_context_step(context))
|
|
233
|
+
context_loaded = True
|
|
234
|
+
steps.append(_copy_step(instr, _subst(rest, args), workdir, bool(import_image)))
|
|
235
|
+
else:
|
|
236
|
+
raise ex.SolariCompatUnsupported(instr)
|
|
237
|
+
|
|
238
|
+
for cmd in system_setup_commands or []:
|
|
239
|
+
steps.append(_run("solari-run " + shlex.quote(cmd)) if import_image else _run(cmd))
|
|
240
|
+
for path, contents in (file_mounts or {}).items():
|
|
241
|
+
b64 = base64.b64encode(contents.encode()).decode()
|
|
242
|
+
dest = (ROOT_DIR + posixpath.normpath(path)) if import_image else path
|
|
243
|
+
steps.append(_run(f"mkdir -p \"$(dirname {shlex.quote(dest)})\" && echo {b64} | base64 -d > {shlex.quote(dest)}"))
|
|
244
|
+
if not seen_from and not from_template:
|
|
245
|
+
base = "base"
|
|
246
|
+
if context_loaded:
|
|
247
|
+
steps.append(_run(f"rm -rf {CONTEXT_DIR}"))
|
|
248
|
+
out: Dict[str, Any] = {"kind": "sandbox", "packages": [], "steps": steps, "fragment": "", "localFiles": []}
|
|
249
|
+
if from_template:
|
|
250
|
+
out["fromTemplate"] = from_template
|
|
251
|
+
else:
|
|
252
|
+
out["base"] = base or "base"
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _subst_run(cmd: str, args: Dict[str, str]) -> str:
|
|
257
|
+
"""Docker expands ARG/ENV in RUN through the shell; here ENV becomes real env steps, so only ARGs need injecting."""
|
|
258
|
+
if not args:
|
|
259
|
+
return cmd
|
|
260
|
+
exports = " ".join(f"{k}={shlex.quote(v)}" for k, v in args.items() if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k))
|
|
261
|
+
return f"export {exports}; {cmd}" if exports else cmd
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _run(command: str) -> Dict[str, Any]:
|
|
265
|
+
return {"op": "run", "command": command}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def import_steps(image: str) -> List[Dict[str, Any]]:
|
|
269
|
+
"""Build steps that pull ``image`` into ``/srv/root`` and install the ``solari-run`` wrapper."""
|
|
270
|
+
b64 = lambda text: base64.b64encode(text.encode()).decode() # noqa: E731
|
|
271
|
+
img = shlex.quote(image)
|
|
272
|
+
crane_ok = f"echo '{CRANE_BIN_SHA256} /usr/local/bin/crane' | sha256sum -c - >/dev/null || {{ echo 'solari: crane binary checksum mismatch' >&2; exit 1; }}"
|
|
273
|
+
size_check = f"python3 /usr/local/bin/solari-size-check \"$SOLARI_IMAGE\" {MAX_COMPRESSED_BYTES}"
|
|
274
|
+
return [
|
|
275
|
+
_run(
|
|
276
|
+
"set -e; mkdir -p /usr/local/bin; if [ ! -x /usr/local/bin/crane ]; then curl -fsSL " + shlex.quote(CRANE_URL) + " -o /tmp/crane.tgz && "
|
|
277
|
+
f"echo '{CRANE_SHA256} /tmp/crane.tgz' | sha256sum -c - && tar -xzf /tmp/crane.tgz -C /usr/local/bin crane && rm -f /tmp/crane.tgz; fi; " + crane_ok
|
|
278
|
+
),
|
|
279
|
+
_run(
|
|
280
|
+
f"echo {b64(SOLARI_EXTRACT)} | base64 -d > /usr/local/bin/solari-extract && echo {b64(SOLARI_SIZECHECK)} | base64 -d > /usr/local/bin/solari-size-check && "
|
|
281
|
+
"chmod 0755 /usr/local/bin/solari-extract /usr/local/bin/solari-size-check"
|
|
282
|
+
),
|
|
283
|
+
# Build steps run under plain sh, so the pipeline (which needs pipefail) runs in an explicit bash.
|
|
284
|
+
_run(
|
|
285
|
+
"bash -c "
|
|
286
|
+
+ shlex.quote(
|
|
287
|
+
"set -eo pipefail; " + crane_ok + f"; export DOCKER_CONFIG=$(mktemp -d) SOLARI_IMAGE={img}; "
|
|
288
|
+
+ size_check
|
|
289
|
+
+ f"; rm -rf {ROOT_DIR}; mkdir -p {ROOT_DIR}; "
|
|
290
|
+
f"timeout {IMPORT_TIMEOUT_S} crane export --platform linux/amd64 {img} - | python3 /usr/local/bin/solari-extract {ROOT_DIR} {MAX_EXTRACTED_BYTES}; "
|
|
291
|
+
'rm -rf "$DOCKER_CONFIG"; '
|
|
292
|
+
f"chroot {ROOT_DIR} /bin/sh -c true 2>/dev/null || {{ echo {shlex.quote('solari: image ' + image + ' has no /bin/sh; image import needs a shell (distroless images are not supported)')} >&2; exit 1; }}"
|
|
293
|
+
)
|
|
294
|
+
),
|
|
295
|
+
_run(
|
|
296
|
+
f"echo {b64(SOLARI_RUN)} | base64 -d > /usr/local/bin/solari-run && chmod 0755 /usr/local/bin/solari-run && "
|
|
297
|
+
f"echo {ROOT_DIR} > /etc/solari-rootdir && : > /etc/solari-env && echo / > /etc/solari-workdir"
|
|
298
|
+
),
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
_GLOB_SAFE = re.compile(r"[A-Za-z0-9_./*?\[\]+@%-]+")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _context_step(context: bytes) -> Dict[str, Any]:
|
|
306
|
+
return _run(f"rm -rf {CONTEXT_DIR} && mkdir -p {CONTEXT_DIR} && echo {base64.b64encode(context).decode()} | base64 -d | tar -xz -C {CONTEXT_DIR}")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _copy_step(instr: str, rest: str, workdir: str, imported: bool) -> Dict[str, Any]:
|
|
310
|
+
"""``COPY``/``ADD`` from the extracted build context into the image (or, for an import, into the image root)."""
|
|
311
|
+
parts = shlex.split(rest) if not rest.startswith("[") else json.loads(rest)
|
|
312
|
+
flags = [p for p in parts if p.startswith("--")]
|
|
313
|
+
parts = [p for p in parts if not p.startswith("--")]
|
|
314
|
+
for f in flags:
|
|
315
|
+
if f.startswith("--from"):
|
|
316
|
+
raise ex.SolariCompatUnsupported(f"{instr} --from (multi-stage)")
|
|
317
|
+
if not (f.startswith("--chown=") or f.startswith("--chmod=")):
|
|
318
|
+
raise ex.SolariCompatUnsupported(f"{instr} {f.split('=')[0]}")
|
|
319
|
+
if len(parts) < 2:
|
|
320
|
+
raise ex.SolariCompatUnsupported(f"{instr} needs at least a source and a destination")
|
|
321
|
+
*srcs, dest = parts
|
|
322
|
+
for src in srcs:
|
|
323
|
+
if "://" in src:
|
|
324
|
+
raise ex.SolariCompatUnsupported(f"{instr} from a URL", "Download with RUN curl instead.")
|
|
325
|
+
if ".." in posixpath.normpath(src).split("/") or src.startswith("/"):
|
|
326
|
+
raise ex.SolariCompatUnsupported(f"{instr} source {src!r}", "Sources must be relative paths inside the build context.")
|
|
327
|
+
abs_dest = posixpath.normpath(dest if dest.startswith("/") else posixpath.join(workdir, dest))
|
|
328
|
+
if dest.endswith("/") or dest in (".", "./"):
|
|
329
|
+
abs_dest = abs_dest.rstrip("/") + "/"
|
|
330
|
+
real_dest = (ROOT_DIR + abs_dest) if imported else abs_dest
|
|
331
|
+
quoted = " ".join(s if _GLOB_SAFE.fullmatch(s) else shlex.quote(s) for s in srcs)
|
|
332
|
+
into_dir = dest.endswith("/") or len(srcs) > 1
|
|
333
|
+
q_dest = shlex.quote(real_dest)
|
|
334
|
+
unpack = instr == "ADD" and len(srcs) == 1 and re.search(r"\.(tar|tar\.gz|tgz)$", srcs[0]) is not None
|
|
335
|
+
if unpack:
|
|
336
|
+
body = f"mkdir -p {q_dest} && tar -xf {quoted} -C {q_dest}"
|
|
337
|
+
elif into_dir:
|
|
338
|
+
body = f"mkdir -p {q_dest} && cp -a -- {quoted} {q_dest}"
|
|
339
|
+
else:
|
|
340
|
+
body = f"mkdir -p \"$(dirname {q_dest})\" && cp -a -- {quoted} {q_dest}"
|
|
341
|
+
post = ""
|
|
342
|
+
for f in flags:
|
|
343
|
+
if f.startswith("--chown="):
|
|
344
|
+
who = f.split("=", 1)[1]
|
|
345
|
+
post += f" && (chown -R {shlex.quote(who)} {q_dest} 2>/dev/null || true)"
|
|
346
|
+
elif f.startswith("--chmod="):
|
|
347
|
+
post += f" && chmod -R {shlex.quote(f.split('=', 1)[1])} {q_dest}"
|
|
348
|
+
return _run(f"cd {CONTEXT_DIR} && {body}{post}")
|