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.
Files changed (45) hide show
  1. solari_runloop/__init__.py +42 -0
  2. solari_runloop/_branding.py +10 -0
  3. solari_runloop/_client.py +107 -0
  4. solari_runloop/_config.py +57 -0
  5. solari_runloop/_context.py +91 -0
  6. solari_runloop/_dockerfile.py +348 -0
  7. solari_runloop/_exceptions.py +122 -0
  8. solari_runloop/_gateway.py +1048 -0
  9. solari_runloop/_guest/solari-run.sh +14 -0
  10. solari_runloop/_guest/solari_extract.py +101 -0
  11. solari_runloop/_guest/solari_sizecheck.py +14 -0
  12. solari_runloop/_loop.py +54 -0
  13. solari_runloop/_mapping.py +322 -0
  14. solari_runloop/_models.py +13 -0
  15. solari_runloop/_resources.py +416 -0
  16. solari_runloop/_sync.py +76 -0
  17. solari_runloop/lib/__init__.py +0 -0
  18. solari_runloop/lib/polling.py +118 -0
  19. solari_runloop/pagination.py +66 -0
  20. solari_runloop/sdk.py +594 -0
  21. solari_runloop/types/__init__.py +17 -0
  22. solari_runloop/types/blueprint_build_log.py +16 -0
  23. solari_runloop/types/blueprint_build_logs_list_view.py +16 -0
  24. solari_runloop/types/blueprint_build_parameters.py +123 -0
  25. solari_runloop/types/blueprint_view.py +97 -0
  26. solari_runloop/types/devbox_async_execution_detail_view.py +46 -0
  27. solari_runloop/types/devbox_execution_detail_view.py +24 -0
  28. solari_runloop/types/devbox_snapshot_list_view.py +17 -0
  29. solari_runloop/types/devbox_snapshot_view.py +33 -0
  30. solari_runloop/types/devbox_view.py +176 -0
  31. solari_runloop/types/devboxes/__init__.py +0 -0
  32. solari_runloop/types/devboxes/devbox_logs_list_view.py +39 -0
  33. solari_runloop/types/secret_view.py +23 -0
  34. solari_runloop/types/shared/__init__.py +0 -0
  35. solari_runloop/types/shared/after_idle.py +15 -0
  36. solari_runloop/types/shared/code_mount_parameters.py +30 -0
  37. solari_runloop/types/shared/launch_parameters.py +104 -0
  38. solari_runloop/types/shared/lifecycle_configuration.py +38 -0
  39. solari_runloop/types/shared/lifecycle_hooks.py +28 -0
  40. solari_runloop/types/shared/resume_triggers.py +17 -0
  41. solari_runloop/types/tunnel_view.py +47 -0
  42. solari_runloop_compat-0.1.0.dist-info/METADATA +47 -0
  43. solari_runloop_compat-0.1.0.dist-info/RECORD +45 -0
  44. solari_runloop_compat-0.1.0.dist-info/WHEEL +4 -0
  45. 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}")