snowflake-sandbox-python 0.2.1a1__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.
- snowflake/cli_sandbox/__init__.py +13 -0
- snowflake/cli_sandbox/_adapter.py +170 -0
- snowflake/cli_sandbox/_common.py +77 -0
- snowflake/cli_sandbox/_egress_flags.py +121 -0
- snowflake/cli_sandbox/_get_command.py +109 -0
- snowflake/cli_sandbox/_run_command.py +1091 -0
- snowflake/cli_sandbox/_shell_command.py +666 -0
- snowflake/cli_sandbox/_upload_plan.py +187 -0
- snowflake/cli_sandbox/commands.py +556 -0
- snowflake/cli_sandbox/plugin_spec.py +28 -0
- snowflake/cli_sandbox/py.typed +0 -0
- snowflake/sandbox/__init__.py +317 -0
- snowflake/sandbox/__main__.py +225 -0
- snowflake/sandbox/_ansi.py +206 -0
- snowflake/sandbox/_args.py +208 -0
- snowflake/sandbox/_assemble.py +256 -0
- snowflake/sandbox/_bundle.py +240 -0
- snowflake/sandbox/_connection_resolve.py +328 -0
- snowflake/sandbox/_deploy_spec.py +56 -0
- snowflake/sandbox/_diagnostics.py +501 -0
- snowflake/sandbox/_env.py +143 -0
- snowflake/sandbox/_files_mixin.py +280 -0
- snowflake/sandbox/_fs_ops.py +304 -0
- snowflake/sandbox/_globs.py +176 -0
- snowflake/sandbox/_hosts.py +110 -0
- snowflake/sandbox/_mcp_discovery.py +288 -0
- snowflake/sandbox/_mcp_status.py +183 -0
- snowflake/sandbox/_retry.py +94 -0
- snowflake/sandbox/_runtime/__init__.py +42 -0
- snowflake/sandbox/_runtime/_fs_helper.py +93 -0
- snowflake/sandbox/_runtime/_job_runner.py +111 -0
- snowflake/sandbox/_runtime/_protocol.py +53 -0
- snowflake/sandbox/_runtime/_shims.py +267 -0
- snowflake/sandbox/_sandbox_state.py +303 -0
- snowflake/sandbox/_session_registry.py +222 -0
- snowflake/sandbox/_sse.py +160 -0
- snowflake/sandbox/_stage.py +270 -0
- snowflake/sandbox/_sync_files_mixin.py +272 -0
- snowflake/sandbox/_sync_fs_ops.py +185 -0
- snowflake/sandbox/_sync_transport.py +737 -0
- snowflake/sandbox/_sync_watch.py +99 -0
- snowflake/sandbox/_transport.py +1366 -0
- snowflake/sandbox/_transport_errors.py +270 -0
- snowflake/sandbox/_upload_plan.py +497 -0
- snowflake/sandbox/_version.py +37 -0
- snowflake/sandbox/_watch.py +164 -0
- snowflake/sandbox/_wire.py +348 -0
- snowflake/sandbox/app.py +256 -0
- snowflake/sandbox/client.py +2356 -0
- snowflake/sandbox/config.py +1133 -0
- snowflake/sandbox/connect.py +288 -0
- snowflake/sandbox/deploy.py +499 -0
- snowflake/sandbox/egress.py +388 -0
- snowflake/sandbox/exceptions.py +253 -0
- snowflake/sandbox/exec_stream.py +264 -0
- snowflake/sandbox/files.py +547 -0
- snowflake/sandbox/function.py +567 -0
- snowflake/sandbox/image.py +46 -0
- snowflake/sandbox/jobs.py +649 -0
- snowflake/sandbox/lifecycle.py +67 -0
- snowflake/sandbox/log_stream.py +219 -0
- snowflake/sandbox/mcp.py +480 -0
- snowflake/sandbox/mount.py +161 -0
- snowflake/sandbox/py.typed +0 -0
- snowflake/sandbox/secret.py +244 -0
- snowflake/sandbox/session_app.py +244 -0
- snowflake/sandbox/shell.py +556 -0
- snowflake/sandbox/sync_client.py +2245 -0
- snowflake/sandbox/sync_exec_stream.py +238 -0
- snowflake/sandbox/sync_files.py +377 -0
- snowflake/sandbox/sync_log_stream.py +142 -0
- snowflake/sandbox/sync_shell.py +413 -0
- snowflake/sandbox/types.py +193 -0
- snowflake/sandbox/warm_session.py +700 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""In-sandbox MCP connection status: "can I use this connector right now?"
|
|
2
|
+
|
|
3
|
+
The client-facing surface here — `McpConnectionStatus`, `mcp_server_status`, and
|
|
4
|
+
the ``MCP_STATUS_*`` vocabulary — is re-exported from `snowflake.sandbox.mcp` (and
|
|
5
|
+
the package root), which is where it is documented and imported from. It lives in
|
|
6
|
+
its own module because it shares nothing with the discovery/SQL half of `mcp`:
|
|
7
|
+
this side reads a JSON file the hostagent writes inside the container and never
|
|
8
|
+
opens a Snowflake connection.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Literal, cast, get_args, overload
|
|
18
|
+
|
|
19
|
+
# Where the hostagent writes the per-server fetch outcome INSIDE the container.
|
|
20
|
+
# This is the authoritative "can I use this connector right now?" signal, and it
|
|
21
|
+
# is deliberately not the account-status view `list_mcp_servers` reads:
|
|
22
|
+
# SYSTEM$GET_USER_INTEGRATION_AUTHORIZATIONS keeps reporting CONNECTED after the
|
|
23
|
+
# OAuth provider revokes a grant, because the revocation happens IdP-side and
|
|
24
|
+
# Snowflake only learns of it when it next tries to use the token. The hostagent
|
|
25
|
+
# does try -- on every refresh cycle -- so its own fetch result is the only
|
|
26
|
+
# source that stays truthful mid-life.
|
|
27
|
+
#
|
|
28
|
+
# The file is a single JSON document keyed by the client-facing server name (the
|
|
29
|
+
# same <NAME> as SANDBOX_MCP_TOKEN_<NAME>), each value carrying
|
|
30
|
+
# {status, fqn, gs_error_code, detail, last_checked_at}. The path, the key, and
|
|
31
|
+
# the status vocabulary below are the contract shared with the hostagent half;
|
|
32
|
+
# change them only in lockstep with it.
|
|
33
|
+
MCP_STATUS_PATH = "/snowflake/mcp/status.json"
|
|
34
|
+
|
|
35
|
+
# The connector is usable: the last per-user token fetch succeeded.
|
|
36
|
+
MCP_STATUS_LIVE: Literal["live"] = "live"
|
|
37
|
+
# The connector needs the user to re-consent in Snowsight. The hostagent maps a
|
|
38
|
+
# Snowflake policy verdict of ACCESS_DENIED or SECRET_NOT_FOUND to this -- the OAuth
|
|
39
|
+
# grant was revoked, or the secret can no longer be resolved for this user, and
|
|
40
|
+
# no retry inside the sandbox will fix it.
|
|
41
|
+
MCP_STATUS_RECONNECT_REQUIRED: Literal["reconnect_required"] = "reconnect_required"
|
|
42
|
+
# The status could not be determined: no status file (an older runtime, or the
|
|
43
|
+
# feature not enabled) or no entry for this name. Fail-soft -- absence is never
|
|
44
|
+
# reported as an outage.
|
|
45
|
+
MCP_STATUS_UNKNOWN: Literal["unknown"] = "unknown"
|
|
46
|
+
|
|
47
|
+
McpLiveStatus = Literal["live", "reconnect_required", "unknown"]
|
|
48
|
+
"""In-sandbox connection status of an EXTERNAL MCP SERVER (`McpConnectionStatus.status`)."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _coerce_live_status(raw: str | None) -> McpLiveStatus:
|
|
52
|
+
"""Clamp a status-file value to the known set; anything else -> ``unknown``."""
|
|
53
|
+
return cast("McpLiveStatus", raw) if raw in get_args(McpLiveStatus) else MCP_STATUS_UNKNOWN
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class McpConnectionStatus:
|
|
58
|
+
"""The live, in-sandbox connection status of one EXTERNAL MCP SERVER.
|
|
59
|
+
|
|
60
|
+
This is what code running *inside* a sandbox should consult before it relies
|
|
61
|
+
on a connector. It reflects the platform's most recent attempt to fetch
|
|
62
|
+
*your* OAuth token for the server, so it is the one signal that stays truthful
|
|
63
|
+
after a provider-side revocation. `McpServerInfo.status` (from
|
|
64
|
+
`list_mcp_servers`) is the account/authorization view and can still read
|
|
65
|
+
``CONNECTED`` when the grant has actually lapsed; when the two disagree, this
|
|
66
|
+
one is right about whether a tool call will succeed.
|
|
67
|
+
|
|
68
|
+
``status`` is one of ``live``, ``reconnect_required``, or ``unknown``. When it
|
|
69
|
+
is ``reconnect_required`` the fix is a one-time re-consent in Snowsight (not a
|
|
70
|
+
retry): ``gs_error_code`` carries the verdict that classified it
|
|
71
|
+
(``ACCESS_DENIED`` / ``SECRET_NOT_FOUND``) and ``detail`` a human-readable
|
|
72
|
+
line. ``unknown`` means no signal was available and must not be treated as an
|
|
73
|
+
outage.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
name: str
|
|
77
|
+
status: McpLiveStatus
|
|
78
|
+
fqn: str | None = None
|
|
79
|
+
gs_error_code: str | None = None
|
|
80
|
+
detail: str | None = None
|
|
81
|
+
last_checked_at: str | None = None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def needs_reconnect(self) -> bool:
|
|
85
|
+
"""True only when the server is known to need a re-consent."""
|
|
86
|
+
return self.status == MCP_STATUS_RECONNECT_REQUIRED
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def live(self) -> bool:
|
|
90
|
+
"""True only when the last token fetch for this server succeeded."""
|
|
91
|
+
return self.status == MCP_STATUS_LIVE
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _status_entry(name: str, raw: object) -> McpConnectionStatus:
|
|
95
|
+
"""Build one `McpConnectionStatus` from a status-file value, fail-soft.
|
|
96
|
+
|
|
97
|
+
Any missing or non-string field degrades to ``None`` / ``unknown`` rather
|
|
98
|
+
than raising, so a shape the platform tightens later cannot break a caller.
|
|
99
|
+
"""
|
|
100
|
+
if not isinstance(raw, Mapping):
|
|
101
|
+
return McpConnectionStatus(name=name, status=MCP_STATUS_UNKNOWN)
|
|
102
|
+
|
|
103
|
+
def _s(key: str) -> str | None:
|
|
104
|
+
value = raw.get(key)
|
|
105
|
+
return value if isinstance(value, str) and value else None
|
|
106
|
+
|
|
107
|
+
return McpConnectionStatus(
|
|
108
|
+
name=name,
|
|
109
|
+
status=_coerce_live_status(_s("status")),
|
|
110
|
+
fqn=_s("fqn"),
|
|
111
|
+
gs_error_code=_s("gs_error_code"),
|
|
112
|
+
detail=_s("detail"),
|
|
113
|
+
last_checked_at=_s("last_checked_at"),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_status_doc(path: str | os.PathLike[str]) -> dict[str, Any] | None:
|
|
118
|
+
"""Read and parse the status file, or None if absent / unreadable / malformed."""
|
|
119
|
+
try:
|
|
120
|
+
with open(path, encoding="utf-8") as fh:
|
|
121
|
+
doc = json.load(fh)
|
|
122
|
+
except (OSError, ValueError):
|
|
123
|
+
# Missing file (older runtime / feature off), unreadable, or invalid JSON.
|
|
124
|
+
return None
|
|
125
|
+
return doc if isinstance(doc, dict) else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@overload
|
|
129
|
+
def mcp_server_status(name: str, *, path: str | os.PathLike[str] = ...) -> McpConnectionStatus: ...
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@overload
|
|
133
|
+
def mcp_server_status(
|
|
134
|
+
name: None = ..., *, path: str | os.PathLike[str] = ...
|
|
135
|
+
) -> list[McpConnectionStatus]: ...
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def mcp_server_status(
|
|
139
|
+
name: str | None = None,
|
|
140
|
+
*,
|
|
141
|
+
path: str | os.PathLike[str] = MCP_STATUS_PATH,
|
|
142
|
+
) -> McpConnectionStatus | list[McpConnectionStatus]:
|
|
143
|
+
"""Read the live connection status the platform publishes inside the sandbox.
|
|
144
|
+
|
|
145
|
+
Call this from code running *in* a sandbox to learn whether an MCP connector
|
|
146
|
+
is usable right now, or whether the user must re-consent to it. Unlike
|
|
147
|
+
`list_mcp_servers` -- which queries the account and can report a revoked grant
|
|
148
|
+
as still ``CONNECTED`` -- this reads the platform's own most-recent
|
|
149
|
+
token-fetch result, rewritten each refresh cycle, so it catches a
|
|
150
|
+
provider-side revocation the account view misses.
|
|
151
|
+
|
|
152
|
+
With a ``name`` it returns that one server's `McpConnectionStatus` (the client
|
|
153
|
+
name used in ``mcp_servers=``, i.e. the ``<NAME>`` in
|
|
154
|
+
``SANDBOX_MCP_TOKEN_<NAME>``); with no name it returns one for every server
|
|
155
|
+
the sandbox currently has a signal for. A missing file or a missing entry
|
|
156
|
+
yields status ``unknown`` rather than raising -- absence is never an outage.
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
from snowflake.sandbox import mcp_server_status
|
|
160
|
+
|
|
161
|
+
gh = mcp_server_status("github")
|
|
162
|
+
if gh.needs_reconnect:
|
|
163
|
+
raise SystemExit(
|
|
164
|
+
f"Reconnect {gh.fqn} in Snowsight before running: {gh.detail}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
Parameters
|
|
168
|
+
----------
|
|
169
|
+
name:
|
|
170
|
+
The client-facing server name to look up. Omit to get the full list.
|
|
171
|
+
path:
|
|
172
|
+
Status-file path; defaults to the runtime location and is a parameter
|
|
173
|
+
only so tests need not write to an absolute system path.
|
|
174
|
+
"""
|
|
175
|
+
doc = _load_status_doc(path)
|
|
176
|
+
if name is not None:
|
|
177
|
+
raw = doc.get(name) if doc is not None else None
|
|
178
|
+
if raw is None:
|
|
179
|
+
return McpConnectionStatus(name=name, status=MCP_STATUS_UNKNOWN)
|
|
180
|
+
return _status_entry(name, raw)
|
|
181
|
+
if not doc:
|
|
182
|
+
return []
|
|
183
|
+
return [_status_entry(str(key), value) for key, value in doc.items()]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Backoff + jitter helper for the SDK retry loop.
|
|
2
|
+
|
|
3
|
+
Retry policy:
|
|
4
|
+
|
|
5
|
+
- Cold-start retries on ``503`` or ``409 not_ready``: exponential backoff
|
|
6
|
+
``1, 4, 16, 64s`` capped at 4 attempts.
|
|
7
|
+
- ``429`` honors the ``Retry-After`` header.
|
|
8
|
+
- ``500`` retries once, then surfaces ``SandboxTransportError``.
|
|
9
|
+
- Retries computed here jitter ±25%. The one exception is a ``429`` that carries
|
|
10
|
+
a ``Retry-After``: ``_transport._rate_limit_delay`` jitters that hint *upward
|
|
11
|
+
only*, because retrying before the moment the server named would re-hammer a
|
|
12
|
+
gateway that just asked us to back off.
|
|
13
|
+
|
|
14
|
+
Every one of those is gated on **idempotency**: for a non-idempotent method
|
|
15
|
+
(POST) a retry is issued only when the failure proves the server never ran the
|
|
16
|
+
request. See ``_transport._IDEMPOTENT_METHODS`` for the rule and why.
|
|
17
|
+
|
|
18
|
+
The helper here only computes delays; the wiring into specific HTTP status
|
|
19
|
+
codes lives in ``_transport.py``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import random
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"compute_backoff",
|
|
28
|
+
"DEFAULT_COLD_START_DELAYS_S",
|
|
29
|
+
"DEFAULT_500_DELAYS_S",
|
|
30
|
+
"DEFAULT_LOGS_500_DELAYS_S",
|
|
31
|
+
"DEFAULT_NOT_FOUND_DELAYS_S",
|
|
32
|
+
"DEFAULT_START_FAILURE_DELAYS_S",
|
|
33
|
+
"is_retryable_start_failure",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
DEFAULT_COLD_START_DELAYS_S: tuple[float, ...] = (1.0, 4.0, 16.0, 64.0)
|
|
37
|
+
"""Delays before attempt 1, 2, 3, 4 of cold-start retries (4 attempts total)."""
|
|
38
|
+
|
|
39
|
+
DEFAULT_500_DELAYS_S: tuple[float, ...] = (1.0,)
|
|
40
|
+
"""Single retry budget for 500s, then surface as SandboxTransportError."""
|
|
41
|
+
|
|
42
|
+
DEFAULT_NOT_FOUND_DELAYS_S: tuple[float, ...] = (0.5, 1.0, 2.0, 4.0)
|
|
43
|
+
"""Retries for a 404 right after create: a freshly-created container is not
|
|
44
|
+
yet routable for a moment. Bounded (~7.5s) so a genuinely-missing container
|
|
45
|
+
still surfaces SandboxNotFoundError quickly."""
|
|
46
|
+
|
|
47
|
+
DEFAULT_LOGS_500_DELAYS_S: tuple[float, ...] = (1.0, 2.0, 4.0, 8.0)
|
|
48
|
+
"""Longer 500 budget (~15s) for the read-only logs route. Fetching logs while a
|
|
49
|
+
container is mid-run can hit a transient upstream 500 that outlasts the single
|
|
50
|
+
default 500 retry; a GET is idempotent so extending the budget is safe. Bounded
|
|
51
|
+
so a persistent 500 still surfaces SandboxTransportError."""
|
|
52
|
+
|
|
53
|
+
DEFAULT_START_FAILURE_DELAYS_S: tuple[float, ...] = (2.0, 8.0)
|
|
54
|
+
"""Backoff before re-creating a container after a *retryable* StartApp failure
|
|
55
|
+
(2 re-creates → 3 create attempts total). The CNG controller has already
|
|
56
|
+
exhausted its own in-place StartApp attempts and tagged the failure retryable, so
|
|
57
|
+
a fresh container may land on a healthy hostagent. Kept small so create() never
|
|
58
|
+
hangs on a persistent condition."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_retryable_start_failure(status: str, error_message: str | None) -> bool:
|
|
62
|
+
"""True when a create settled on a terminal ``failed`` the server tagged retryable.
|
|
63
|
+
|
|
64
|
+
There is no structured ``retryable`` flag on the wire today, so this matches the
|
|
65
|
+
``(retryable)`` marker CNG's controller emits after it exhausts its own StartApp
|
|
66
|
+
attempts. Scoped to ``create()``'s own readiness wait: the message is hydrated
|
|
67
|
+
from *this* attempt's failed-container poll immediately before the raise, so the
|
|
68
|
+
staleness that makes ``_error_message`` unusable elsewhere does not apply here.
|
|
69
|
+
"""
|
|
70
|
+
return status == "failed" and "(retryable)" in (error_message or "")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def compute_backoff(
|
|
74
|
+
attempt: int,
|
|
75
|
+
*,
|
|
76
|
+
delays: tuple[float, ...] = DEFAULT_COLD_START_DELAYS_S,
|
|
77
|
+
jitter: float = 0.25,
|
|
78
|
+
rng: random.Random | None = None,
|
|
79
|
+
) -> float | None:
|
|
80
|
+
"""Return the delay in seconds before the given retry attempt.
|
|
81
|
+
|
|
82
|
+
``attempt`` is 1-indexed (the first retry is ``attempt=1``). Returns
|
|
83
|
+
``None`` if the attempt is past the budget — the caller surfaces the
|
|
84
|
+
terminal exception.
|
|
85
|
+
|
|
86
|
+
Jitter is full-symmetric: the returned delay is uniformly sampled from
|
|
87
|
+
``[base * (1 - jitter), base * (1 + jitter)]``.
|
|
88
|
+
"""
|
|
89
|
+
if attempt < 1 or attempt > len(delays):
|
|
90
|
+
return None
|
|
91
|
+
base = delays[attempt - 1]
|
|
92
|
+
r = rng if rng is not None else random.Random()
|
|
93
|
+
spread = base * jitter
|
|
94
|
+
return max(0.0, base + r.uniform(-spread, spread))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Code that runs on the **far side** of the sandbox boundary.
|
|
2
|
+
|
|
3
|
+
Everything in this subpackage executes *inside* a sandbox container, not in the
|
|
4
|
+
calling process. That is the whole reason it has a home of its own: the boundary
|
|
5
|
+
is otherwise invisible. These are not helpers the SDK calls — they are programs
|
|
6
|
+
the SDK *writes out and ships*, to be run later, by a different interpreter, on
|
|
7
|
+
another machine, with only the base image's stdlib available.
|
|
8
|
+
|
|
9
|
+
What follows from that, and is easy to forget when the same text sits inline in a
|
|
10
|
+
client-side module:
|
|
11
|
+
|
|
12
|
+
* **No SDK imports.** The container's base image ships (at most) the *published*
|
|
13
|
+
SDK, which may be older than the one deploying. In-container programs stand on
|
|
14
|
+
the stdlib alone — every value they need is baked into their source as a
|
|
15
|
+
literal, never imported at run time.
|
|
16
|
+
* **The programs are not type-checked or linted.** These modules hold their
|
|
17
|
+
source as *strings*; mypy and ruff see the quotes, not the program. A typo
|
|
18
|
+
inside one surfaces as a container that dies on start, hundreds of milliseconds
|
|
19
|
+
of network away from the traceback that would have named it.
|
|
20
|
+
* **Every string is a wire protocol.** The stdout sentinels, the mailbox path and
|
|
21
|
+
the marker-attribute names are read by the *other* side. Changing one is a
|
|
22
|
+
compatibility break against sandboxes already running, not a rename — which is
|
|
23
|
+
why they are defined once, in `_protocol`, and imported by both halves.
|
|
24
|
+
|
|
25
|
+
Layout:
|
|
26
|
+
|
|
27
|
+
* `_protocol` — the constants both sides must agree on (sentinels, mailbox path,
|
|
28
|
+
nonce env var, marker attributes, inotify mask bits).
|
|
29
|
+
* `_job_runner` — the bash runner ``deploy_async`` injects to wrap a detached run.
|
|
30
|
+
* `_shims` — the generated python entrypoints: the ``@app.session`` daemon, the
|
|
31
|
+
``@app.function`` runner, and the detached-command wrapper.
|
|
32
|
+
* `_fs_helper` — the ``python3 -c`` programs behind the directory ops and the
|
|
33
|
+
``inotify(7)`` file watcher.
|
|
34
|
+
|
|
35
|
+
The one piece of in-container code deliberately *not* here is
|
|
36
|
+
`snowflake.sandbox.warm_session.session_loop`. It runs in the container too, but it is
|
|
37
|
+
public API the *user* imports and calls from their own entrypoint — ordinary
|
|
38
|
+
typed, linted, documented library code — so it keeps a public home. What lives
|
|
39
|
+
here is the code the SDK generates and the caller never sees.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""The ``python3 -c`` programs behind the filesystem surface.
|
|
2
|
+
|
|
3
|
+
Two of the three filesystem mechanisms have no server route, so they run as
|
|
4
|
+
source text in the container over ``exec`` / ``exec_stream`` instead:
|
|
5
|
+
|
|
6
|
+
* the **directory ops** (``list_files`` / ``stat`` / ``make_directory`` /
|
|
7
|
+
``remove``) — the ``/files`` route transfers bytes only, so these are ordinary
|
|
8
|
+
in-container filesystem calls that print structured JSON back;
|
|
9
|
+
* the **file watcher** — a ``ctypes``-driven ``inotify(7)`` monitor, reaching a
|
|
10
|
+
kernel facility through ``libc`` so it needs no container package (the image
|
|
11
|
+
ships neither an ``inotifywait`` binary nor a python inotify library).
|
|
12
|
+
|
|
13
|
+
Both embed their arguments as ``repr``'d Python literals or pass them as separate
|
|
14
|
+
argv entries — never shell-interpolated — so a path containing spaces, quotes or
|
|
15
|
+
shell metacharacters cannot break out of the program.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from snowflake.sandbox._runtime._protocol import (
|
|
21
|
+
_FS_SENTINEL,
|
|
22
|
+
_IN_CLOSE_WRITE,
|
|
23
|
+
_IN_CREATE,
|
|
24
|
+
_IN_DELETE,
|
|
25
|
+
_IN_MODIFY,
|
|
26
|
+
_IN_MOVED_FROM,
|
|
27
|
+
_IN_MOVED_TO,
|
|
28
|
+
_WATCH_READY,
|
|
29
|
+
_WATCH_SENTINEL,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = ["_FS_HELPER_PREAMBLE", "_watch_program"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_FS_HELPER_PREAMBLE = f"""\
|
|
36
|
+
import json, os, shutil, sys
|
|
37
|
+
def _emit(obj):
|
|
38
|
+
sys.stdout.write({_FS_SENTINEL!r} + json.dumps(obj) + "\\n")
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _watch_program() -> str:
|
|
43
|
+
"""The in-container inotify monitor, as a python3 ``-c`` source string.
|
|
44
|
+
|
|
45
|
+
Prints ``_WATCH_READY`` once armed, then one ``_WATCH_SENTINEL{json}`` line
|
|
46
|
+
per event (``{"mask": int, "path": abs}``). argv: ``[root, recursive01,
|
|
47
|
+
timeout_or_empty]``. inotify is not natively recursive, so with recursion it
|
|
48
|
+
walks the tree at start and adds a watch for each directory created
|
|
49
|
+
afterward, keeping the watch set complete.
|
|
50
|
+
"""
|
|
51
|
+
mask = _IN_CREATE | _IN_MOVED_TO | _IN_DELETE | _IN_MOVED_FROM | _IN_MODIFY | _IN_CLOSE_WRITE
|
|
52
|
+
# IN_ISDIR rides the high bit of an event mask when the subject is a dir.
|
|
53
|
+
return f"""\
|
|
54
|
+
import ctypes, ctypes.util, os, struct, select, sys, time, json
|
|
55
|
+
root = sys.argv[1]
|
|
56
|
+
recursive = sys.argv[2] == "1"
|
|
57
|
+
timeout = float(sys.argv[3]) if sys.argv[3] else None
|
|
58
|
+
IN_ISDIR = 0x40000000
|
|
59
|
+
MASK = {mask}
|
|
60
|
+
libc = ctypes.CDLL(ctypes.util.find_library("c") or "libc.so.6", use_errno=True)
|
|
61
|
+
fd = libc.inotify_init1(os.O_NONBLOCK)
|
|
62
|
+
if fd < 0:
|
|
63
|
+
sys.stderr.write("inotify_init1 failed errno=%d\\n" % ctypes.get_errno()); sys.exit(2)
|
|
64
|
+
wds = {{}}
|
|
65
|
+
def add(path):
|
|
66
|
+
wd = libc.inotify_add_watch(fd, path.encode(), MASK)
|
|
67
|
+
if wd >= 0:
|
|
68
|
+
wds[wd] = path
|
|
69
|
+
return wd
|
|
70
|
+
if add(root) < 0:
|
|
71
|
+
sys.stderr.write("inotify_add_watch failed for %r errno=%d\\n" % (root, ctypes.get_errno())); sys.exit(3)
|
|
72
|
+
if recursive:
|
|
73
|
+
for dp, dns, _fns in os.walk(root):
|
|
74
|
+
for d in dns:
|
|
75
|
+
add(os.path.join(dp, d))
|
|
76
|
+
sys.stdout.write({_WATCH_READY!r} + "\\n"); sys.stdout.flush()
|
|
77
|
+
deadline = None if timeout is None else time.time() + timeout
|
|
78
|
+
while deadline is None or time.time() < deadline:
|
|
79
|
+
wait = 1.0 if deadline is None else max(0.0, deadline - time.time())
|
|
80
|
+
if not select.select([fd], [], [], wait)[0]:
|
|
81
|
+
continue
|
|
82
|
+
buf = os.read(fd, 8192)
|
|
83
|
+
i = 0
|
|
84
|
+
while i + 16 <= len(buf):
|
|
85
|
+
wd, m, _cookie, ln = struct.unpack_from("iIII", buf, i); i += 16
|
|
86
|
+
name = buf[i:i+ln].split(b"\\x00", 1)[0].decode("utf-8", "replace"); i += ln
|
|
87
|
+
base = wds.get(wd, root)
|
|
88
|
+
full = os.path.join(base, name) if name else base
|
|
89
|
+
if recursive and (m & {_IN_CREATE}) and (m & IN_ISDIR):
|
|
90
|
+
add(full)
|
|
91
|
+
sys.stdout.write({_WATCH_SENTINEL!r} + json.dumps({{"mask": m, "path": full}}) + "\\n")
|
|
92
|
+
sys.stdout.flush()
|
|
93
|
+
"""
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The bash runner ``deploy_async`` injects to wrap a detached run.
|
|
2
|
+
|
|
3
|
+
Written into the bundle as ``_sandbox_job_runner.sh`` and made the container's
|
|
4
|
+
entry, so it — not the user's command — is PID 1's job. It runs the manifest's
|
|
5
|
+
original ``[build].entry`` as a *subprocess* (not ``exec``) precisely so it
|
|
6
|
+
regains control afterwards and can report the outcome: print the result sentinel
|
|
7
|
+
to stdout, and best-effort POST the webhook.
|
|
8
|
+
|
|
9
|
+
Bash, not python, because this is the layer that must work before anything is
|
|
10
|
+
known about the workload. It assumes only what the base image guarantees: bash,
|
|
11
|
+
python3 (for json/base64, so no dependency on a ``base64`` CLI flavour) and curl.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from snowflake.sandbox._runtime._protocol import _RESULT_SENTINEL
|
|
17
|
+
|
|
18
|
+
__all__ = ["_SANDBOX_JOB_RUNNER"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# The ``__RESULT_SENTINEL__`` token below is substituted with _RESULT_SENTINEL at
|
|
22
|
+
# import time so the sentinel prefix stays defined in exactly one place.
|
|
23
|
+
_SANDBOX_JOB_RUNNER = r"""#!/bin/bash
|
|
24
|
+
# Injected by deploy_async. Runs the inner entry, captures its exit code and
|
|
25
|
+
# $SANDBOX_RESULT artifact, prints the result sentinel to stdout, and best-effort
|
|
26
|
+
# POSTs the webhook. Preserves the inner exit code. No stage write and no
|
|
27
|
+
# Snowflake calls: the webhook needs only egress.
|
|
28
|
+
set -u
|
|
29
|
+
|
|
30
|
+
SANDBOX_RESULT="${SANDBOX_RESULT:-/sandbox/result.json}"
|
|
31
|
+
export SANDBOX_RESULT
|
|
32
|
+
_SANDBOX_WEBHOOK_PAYLOAD="/tmp/_sandbox_webhook_payload.json"
|
|
33
|
+
|
|
34
|
+
# Take the per-run nonce out of the environment before running the inner entry,
|
|
35
|
+
# so the untrusted workload cannot read it and forge its own result sentinel.
|
|
36
|
+
_SANDBOX_JOB_NONCE="${SANDBOX_JOB_NONCE:-}"
|
|
37
|
+
unset SANDBOX_JOB_NONCE
|
|
38
|
+
|
|
39
|
+
# The inner entry writes its structured result to $SANDBOX_RESULT; make sure its
|
|
40
|
+
# parent dir exists (the default /sandbox is not present in the base image), and
|
|
41
|
+
# is writable, so the inner write doesn't FileNotFoundError. Best-effort: if the
|
|
42
|
+
# default location can't be created, fall back to a tmp path.
|
|
43
|
+
if ! mkdir -p "$(dirname "$SANDBOX_RESULT")" 2>/dev/null; then
|
|
44
|
+
SANDBOX_RESULT="/tmp/_sandbox_result.json"
|
|
45
|
+
export SANDBOX_RESULT
|
|
46
|
+
mkdir -p "$(dirname "$SANDBOX_RESULT")" 2>/dev/null || true
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
# Decode the JSON-encoded inner entry argv into the _inner array.
|
|
50
|
+
mapfile -t _inner < <(printf '%s' "${SANDBOX_JOB_INNER_ENTRY:-[]}" | python3 -c '
|
|
51
|
+
import json, sys
|
|
52
|
+
for a in json.load(sys.stdin):
|
|
53
|
+
print(a)
|
|
54
|
+
')
|
|
55
|
+
|
|
56
|
+
if [ "${#_inner[@]}" -eq 0 ]; then
|
|
57
|
+
echo "sandbox-job-runner: empty SANDBOX_JOB_INNER_ENTRY" >&2
|
|
58
|
+
exit 2
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
# Run the inner entry as a subprocess (not exec) so we regain control to emit
|
|
62
|
+
# the sentinel + webhook after it completes.
|
|
63
|
+
"${_inner[@]}"
|
|
64
|
+
inner_exit=$?
|
|
65
|
+
|
|
66
|
+
if [ "$inner_exit" -eq 0 ]; then
|
|
67
|
+
_status="succeeded"
|
|
68
|
+
else
|
|
69
|
+
_status="failed"
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
# Build the result JSON (parse $SANDBOX_RESULT if present, else {exit_code:N}),
|
|
73
|
+
# print the sentinel line to stdout (the log channel the SDK scans), and write
|
|
74
|
+
# the webhook payload to a file for the curl below.
|
|
75
|
+
SANDBOX_JOB_STATUS="$_status" \
|
|
76
|
+
SANDBOX_JOB_EXIT="$inner_exit" \
|
|
77
|
+
SANDBOX_RESULT="$SANDBOX_RESULT" \
|
|
78
|
+
SANDBOX_JOB_SENTINEL="__RESULT_SENTINEL__" \
|
|
79
|
+
SANDBOX_JOB_NONCE="${_SANDBOX_JOB_NONCE:-}" \
|
|
80
|
+
SANDBOX_JOB_WEBHOOK_PAYLOAD="$_SANDBOX_WEBHOOK_PAYLOAD" \
|
|
81
|
+
python3 -c '
|
|
82
|
+
import base64, json, os
|
|
83
|
+
status = os.environ["SANDBOX_JOB_STATUS"]
|
|
84
|
+
exit_code = int(os.environ["SANDBOX_JOB_EXIT"])
|
|
85
|
+
result = None
|
|
86
|
+
try:
|
|
87
|
+
with open(os.environ["SANDBOX_RESULT"]) as fh:
|
|
88
|
+
result = json.load(fh)
|
|
89
|
+
except Exception:
|
|
90
|
+
result = None
|
|
91
|
+
if not isinstance(result, dict):
|
|
92
|
+
result = {"exit_code": exit_code}
|
|
93
|
+
core = {"status": status, "exit_code": exit_code, "result": result,
|
|
94
|
+
"nonce": os.environ.get("SANDBOX_JOB_NONCE", "")}
|
|
95
|
+
print(os.environ["SANDBOX_JOB_SENTINEL"] + base64.b64encode(json.dumps(core).encode()).decode())
|
|
96
|
+
webhook = dict(core)
|
|
97
|
+
webhook["job_id"] = os.environ.get("SANDBOX_JOB_ID", "")
|
|
98
|
+
with open(os.environ["SANDBOX_JOB_WEBHOOK_PAYLOAD"], "w") as fh:
|
|
99
|
+
json.dump(webhook, fh)
|
|
100
|
+
'
|
|
101
|
+
|
|
102
|
+
# Best-effort webhook POST (failures swallowed). Needs only egress.
|
|
103
|
+
if [ -n "${SANDBOX_JOB_WEBHOOK:-}" ] && [ -f "$_SANDBOX_WEBHOOK_PAYLOAD" ]; then
|
|
104
|
+
curl -m 10 -s -X POST \
|
|
105
|
+
-H 'Content-Type: application/json' \
|
|
106
|
+
-d @"$_SANDBOX_WEBHOOK_PAYLOAD" \
|
|
107
|
+
"$SANDBOX_JOB_WEBHOOK" >/dev/null 2>&1 || true
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
exit "$inner_exit"
|
|
111
|
+
""".replace("__RESULT_SENTINEL__", _RESULT_SENTINEL)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The values both sides of the sandbox boundary must agree on.
|
|
2
|
+
|
|
3
|
+
Each of these is read by a *different process* from the one that writes it: the
|
|
4
|
+
in-container programs in this package bake them into their source as literals,
|
|
5
|
+
and the client-side modules use the same names to parse what comes back. Defining
|
|
6
|
+
them once, here, is what keeps the two halves in step — the alternative is a
|
|
7
|
+
literal repeated in a source string, where no test and no type checker can notice
|
|
8
|
+
it drifting.
|
|
9
|
+
|
|
10
|
+
They are therefore a **compatibility surface**, not internal names. A sandbox that
|
|
11
|
+
was deployed by an older SDK is still running the older text; changing a value
|
|
12
|
+
here stops the current client from recognising that container's output.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
# The stdout sentinel the in-sandbox runner prints on completion. The payload
|
|
18
|
+
# after the prefix is base64(json) where json = {status, exit_code, result}.
|
|
19
|
+
_RESULT_SENTINEL = "__SANDBOX_RESULT__"
|
|
20
|
+
|
|
21
|
+
# The mailbox file the daemon tails inside the container.
|
|
22
|
+
_MAILBOX_PATH = "/tmp/sandbox_mailbox.jsonl"
|
|
23
|
+
|
|
24
|
+
# The stdout sentinel the in-container daemon prints per reply. The payload is
|
|
25
|
+
# ``<msg_id>__<nonce>__<base64(json(reply))>`` so a reply is matched to its message
|
|
26
|
+
# id AND authenticated by the per-session nonce, which keeps the untrusted
|
|
27
|
+
# workload from forging a reply for someone else's message.
|
|
28
|
+
_REPLY_SENTINEL = "__SANDBOX_REPLY__"
|
|
29
|
+
|
|
30
|
+
# Attribute the marker stamps onto a method so the generated session shim can
|
|
31
|
+
# discover the cold-start hook by introspection (mirrors function-mode's
|
|
32
|
+
# discovery of fn.__name__). Kept as a plain attribute flag — no wrapper class —
|
|
33
|
+
# so the method is still a normal bound method on the user's instance.
|
|
34
|
+
_ENTER_FLAG = "__sandbox_session_enter__"
|
|
35
|
+
|
|
36
|
+
# The stdout sentinel the in-container filesystem helper prefixes its JSON with.
|
|
37
|
+
_FS_SENTINEL = "__SBX_FS__"
|
|
38
|
+
|
|
39
|
+
# The stdout sentinels the in-container inotify monitor prints: one READY line
|
|
40
|
+
# once the watch is armed, then one prefixed JSON line per event.
|
|
41
|
+
_WATCH_SENTINEL = "__SBX_WATCH__"
|
|
42
|
+
_WATCH_READY = _WATCH_SENTINEL + "READY"
|
|
43
|
+
|
|
44
|
+
# Raw inotify(7) mask bits (uapi/linux/inotify.h) -- hard-coded so the in-container
|
|
45
|
+
# program needs no imports beyond the stdlib.
|
|
46
|
+
_IN_MODIFY = 0x00000002
|
|
47
|
+
_IN_CLOSE_WRITE = 0x00000008
|
|
48
|
+
_IN_MOVED_FROM = 0x00000040
|
|
49
|
+
_IN_MOVED_TO = 0x00000080
|
|
50
|
+
_IN_CREATE = 0x00000100
|
|
51
|
+
_IN_DELETE = 0x00000200
|
|
52
|
+
_IN_ACCESS = 0x00000001
|
|
53
|
+
_IN_OPEN = 0x00000020
|