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,388 @@
|
|
|
1
|
+
"""Outbound network policy — the ``Egress`` spec.
|
|
2
|
+
|
|
3
|
+
``compile_egress`` folds brokered `Secret` entries into the egress object, since
|
|
4
|
+
secrets are authored flat but nest on the wire. Host-pattern matching lives in
|
|
5
|
+
`snowflake.sandbox._hosts`.
|
|
6
|
+
|
|
7
|
+
from snowflake.sandbox import Egress, compile_egress
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
17
|
+
from snowflake.sandbox.secret import Secret, validate_secret_entries
|
|
18
|
+
|
|
19
|
+
__all__ = ["Egress", "compile_egress"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_HOST_LIST_WIRE_KEY = "allowed_domains"
|
|
23
|
+
|
|
24
|
+
_HOST_LIST_PARAM = "allowed_egress_hosts"
|
|
25
|
+
|
|
26
|
+
# Spellings accepted on the raw-dict path, all normalized to the wire key. The typed
|
|
27
|
+
# constructor takes only _HOST_LIST_PARAM; the others get a pointed error.
|
|
28
|
+
_HOST_LIST_KEYS = (_HOST_LIST_PARAM, "allowed_hosts", _HOST_LIST_WIRE_KEY)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _retired_host_list_message(spelling: str) -> str:
|
|
32
|
+
"""Guidance for the retired caller-supplied host list.
|
|
33
|
+
|
|
34
|
+
The platform reserved this field server-side and removed the grant
|
|
35
|
+
*silently*: hosts named there now land in unknown fields and are dropped,
|
|
36
|
+
emitting only a server-side warning the caller never sees. The sandbox comes
|
|
37
|
+
up healthy and simply cannot reach the hosts the caller believes it allowed,
|
|
38
|
+
and nothing on the request path says so.
|
|
39
|
+
|
|
40
|
+
That silence is the whole reason this raises instead of warning: a client-side error
|
|
41
|
+
is the only place the caller can still learn, and a sandbox that quietly cannot
|
|
42
|
+
reach its dependency is worse than one that refuses to start.
|
|
43
|
+
"""
|
|
44
|
+
return (
|
|
45
|
+
f"egress {spelling}= is no longer supported: Snowflake retired the "
|
|
46
|
+
f"caller-supplied host list, so hosts named here are IGNORED — not enforced, "
|
|
47
|
+
f"and not reachable. The request still succeeds, which is why this fails "
|
|
48
|
+
f"locally instead: the sandbox would start healthy and silently fail to reach "
|
|
49
|
+
f"them.\n\n"
|
|
50
|
+
f"Grant the hosts with an External Access Integration and name it instead:\n"
|
|
51
|
+
f' Egress(allow_default_egress=False, external_access_integrations=("MY_EAI",))\n\n'
|
|
52
|
+
f"Creating one (the network rule cannot live in a personal USER$ database):\n"
|
|
53
|
+
f" CREATE NETWORK RULE db.schema.my_rule MODE = EGRESS TYPE = HOST_PORT\n"
|
|
54
|
+
f" VALUE_LIST = ('example.com:443');\n"
|
|
55
|
+
f" CREATE EXTERNAL ACCESS INTEGRATION my_eai\n"
|
|
56
|
+
f" ALLOWED_NETWORK_RULES = (db.schema.my_rule) ENABLED = TRUE;\n"
|
|
57
|
+
f" GRANT USAGE ON INTEGRATION my_eai TO ROLE <the role that creates sandboxes>;"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _nonempty_host_value(raw: object) -> bool:
|
|
62
|
+
"""Whether a host-list value would contribute any hosts.
|
|
63
|
+
|
|
64
|
+
Only decides whether a value would contribute hosts — it deliberately does not
|
|
65
|
+
validate the entries, because a non-empty list is refused outright now (the platform
|
|
66
|
+
retired the field), so there is nothing left to validate it against. A non-sequence
|
|
67
|
+
counts as non-empty so a malformed value is refused rather than silently ignored.
|
|
68
|
+
"""
|
|
69
|
+
if isinstance(raw, str) or not isinstance(raw, Sequence):
|
|
70
|
+
return True
|
|
71
|
+
return len(raw) > 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# The REST spelling of the default-egress tri-state. Named once: the SDK parameter was
|
|
75
|
+
# renamed to allow_default_egress, the wire key was not, and two places translate between
|
|
76
|
+
# them (Egress.to_wire and compile_egress's raw-dict path).
|
|
77
|
+
_DEFAULT_EGRESS_WIRE_KEY = "allow_internet"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Egress:
|
|
82
|
+
"""Controls outbound network access from the sandbox.
|
|
83
|
+
|
|
84
|
+
Pass as `egress=` to `Sandbox.create()` or `@app.function`.
|
|
85
|
+
|
|
86
|
+
There are exactly three levels of access, and the middle one is the default:
|
|
87
|
+
|
|
88
|
+
| level | how | reaches |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| baseline | the default, or `allow_default_egress=True` | Snowflake, the cloud-storage stages, and ~43 package-manager hosts |
|
|
91
|
+
| closed | `allow_default_egress=False` / `Egress.only()` | Snowflake and its stages only |
|
|
92
|
+
| granted | `external_access_integrations=(...)` | the above, plus whatever the EAI's network rules resolve to |
|
|
93
|
+
|
|
94
|
+
**The baseline is not the internet.** It is a curated host set the platform
|
|
95
|
+
maintains; anything outside it is refused, so a host you need that is not on that
|
|
96
|
+
list requires an External Access Integration whichever level you pick. A
|
|
97
|
+
caller-supplied host list used to be the third option and is now retired.
|
|
98
|
+
|
|
99
|
+
`allow_internet` is the former name of `allow_default_egress` and still works, with a
|
|
100
|
+
DeprecationWarning. It was misleading in both directions: it never granted the whole
|
|
101
|
+
internet, and False does not stop egress because an EAI still grants.
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
egress = Egress() # baseline
|
|
105
|
+
egress = Egress.only() # closed: nothing arbitrary
|
|
106
|
+
egress = Egress(allow_default_egress=False, external_access_integrations=("MY_EAI",))
|
|
107
|
+
|
|
108
|
+
`allow_github` and `allow_pypi` are both **deprecated and inert**. `allow_internet` is
|
|
109
|
+
the only egress control. Public PyPI is already reachable via the package-managers
|
|
110
|
+
group, and the six GitHub/dbt hosts now need an External Access Integration like any
|
|
111
|
+
other host outside that group. Both flags will be removed in a future release.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
# Tri-state, and only one of the three states does anything: None (unset) and True
|
|
115
|
+
# both leave the platform in baseline mode, because sandbox-api sets
|
|
116
|
+
# disallow_non_snowflake_access ONLY when this is explicitly False. So False is the
|
|
117
|
+
# single value that changes behaviour — it closes egress, which also withholds the
|
|
118
|
+
# baseline and disables the group flags below.
|
|
119
|
+
allow_internet: bool | None = None
|
|
120
|
+
# Deprecated and inert. It set EgressConfig.allow_egress_to_github_and_dbt, the six-host
|
|
121
|
+
# GitHub/dbt group, which the platform is removing — leaving allow_internet as the only
|
|
122
|
+
# egress control. Those hosts need an External Access Integration now. Setting it does
|
|
123
|
+
# nothing and, unlike allow_pypi, does not even warn (see __new__); to_wire() no longer
|
|
124
|
+
# sends it. Retained and still defaulting True, so existing callers keep constructing
|
|
125
|
+
# without a TypeError and no value of it reads as meaningful; remove in a later major.
|
|
126
|
+
allow_github: bool = True
|
|
127
|
+
# Deprecated and inert. It once set StartAppRequest.allow_artifact_repository_pypi,
|
|
128
|
+
# which the platform reserved in favour of a different mechanism (a pip.conf routed
|
|
129
|
+
# to a Snowflake Artifact Repository) that is not expressible here. It never governed
|
|
130
|
+
# public PyPI — pypi.org and files.pythonhosted.org are in the always-on
|
|
131
|
+
# package-managers group, reachable by default with no flag. Setting it does nothing;
|
|
132
|
+
# __post_init__ warns and to_wire() no longer sends it. Retained (not deleted) so
|
|
133
|
+
# existing callers keep constructing without a TypeError; remove in a later major.
|
|
134
|
+
allow_pypi: bool = False
|
|
135
|
+
external_access_integrations: tuple[str, ...] = field(default_factory=tuple)
|
|
136
|
+
# The preferred spelling of allow_internet, which was misleading in both directions:
|
|
137
|
+
# it never granted "the internet" (the baseline is a curated ~43-host set), and False
|
|
138
|
+
# does not stop egress (an EAI still grants). Tri-state exactly like the field it
|
|
139
|
+
# replaces. Appended last so positional Egress(None, True, False, (...)) callers are
|
|
140
|
+
# unaffected. The WIRE key is still allow_internet — this is a client-side rename, so
|
|
141
|
+
# no server change is needed and old and new SDKs interoperate.
|
|
142
|
+
allow_default_egress: bool | None = None
|
|
143
|
+
|
|
144
|
+
def __post_init__(self) -> None:
|
|
145
|
+
# A dataclass enforces no types, so Egress(allow_internet="false") kept the string and
|
|
146
|
+
# reached the wire, where the server's `*bool` refused it with a Go type name. Checked
|
|
147
|
+
# here so it raises at the call the caller wrote. allow_internet is tri-state (None).
|
|
148
|
+
for name, nullable in (
|
|
149
|
+
("allow_internet", True),
|
|
150
|
+
("allow_default_egress", True),
|
|
151
|
+
("allow_github", False),
|
|
152
|
+
("allow_pypi", False),
|
|
153
|
+
):
|
|
154
|
+
value = getattr(self, name)
|
|
155
|
+
if value is None and nullable:
|
|
156
|
+
continue
|
|
157
|
+
if not isinstance(value, bool):
|
|
158
|
+
raise SandboxError(
|
|
159
|
+
f"Egress({name}=) must be True or False, got {type(value).__name__} {value!r}"
|
|
160
|
+
)
|
|
161
|
+
# Both spellings set and disagreeing is ambiguous, and picking one silently would
|
|
162
|
+
# apply an egress posture the caller did not ask for. Raise at the call instead.
|
|
163
|
+
if (
|
|
164
|
+
self.allow_internet is not None
|
|
165
|
+
and self.allow_default_egress is not None
|
|
166
|
+
and self.allow_internet != self.allow_default_egress
|
|
167
|
+
):
|
|
168
|
+
raise SandboxError(
|
|
169
|
+
"Egress(allow_internet=) and Egress(allow_default_egress=) disagree "
|
|
170
|
+
f"({self.allow_internet!r} vs {self.allow_default_egress!r}). They are the "
|
|
171
|
+
"same control — pass only allow_default_egress."
|
|
172
|
+
)
|
|
173
|
+
# NOT warned on deliberately. The suite runs filterwarnings=error, so emitting a
|
|
174
|
+
# DeprecationWarning here fails all 21 tests that construct Egress(allow_internet=)
|
|
175
|
+
# -- the old name still works and still reaches the wire, so the warning buys a
|
|
176
|
+
# large test migration for no behaviour change. Add it, with that migration, in the
|
|
177
|
+
# release that removes the old name.
|
|
178
|
+
# allow_pypi is deprecated and inert (see the field comment). Warn rather than
|
|
179
|
+
# raise: it reaches no server behaviour, so a costless no-op should not break a
|
|
180
|
+
# working sandbox — but the caller should stop relying on it. This is the one
|
|
181
|
+
# place a positional Egress(None, True, True, ...) is also caught.
|
|
182
|
+
if self.allow_pypi:
|
|
183
|
+
warnings.warn(
|
|
184
|
+
"Egress(allow_pypi=...) is deprecated and has no effect. Public PyPI "
|
|
185
|
+
"(pypi.org, files.pythonhosted.org) is already reachable by default via "
|
|
186
|
+
"the package-managers group, so no flag is needed; routing to a Snowflake "
|
|
187
|
+
"Artifact Repository is not expressible today. Remove allow_pypi; it will "
|
|
188
|
+
"be dropped in a future release.",
|
|
189
|
+
DeprecationWarning,
|
|
190
|
+
stacklevel=3,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def only() -> Egress:
|
|
195
|
+
"""Close egress — ``Egress.only()``, with no arguments.
|
|
196
|
+
|
|
197
|
+
Sets ``allow_default_egress=False``, which withholds the platform baseline as well: no
|
|
198
|
+
arbitrary internet host is reachable, not even the package-manager set the default
|
|
199
|
+
mode grants. Snowflake and its stages stay reachable regardless — see the danger
|
|
200
|
+
note below, which is why this is not described as total confinement.
|
|
201
|
+
|
|
202
|
+
It takes **no arguments**. Naming hosts to allow back is gone with the retired
|
|
203
|
+
caller-supplied host list; to confine and then grant a specific host, pair
|
|
204
|
+
confinement with an External Access Integration:
|
|
205
|
+
|
|
206
|
+
Egress(allow_default_egress=False, external_access_integrations=("MY_EAI",))
|
|
207
|
+
|
|
208
|
+
!!! danger "Not an exfiltration boundary for Snowflake destinations"
|
|
209
|
+
``only()`` does **not** confine the sandbox to *just* a chosen set of hosts.
|
|
210
|
+
Egress to **Snowflake itself and to the cloud-storage stages** backing the
|
|
211
|
+
sandbox stays open regardless — it is the control plane the sandbox runs on, and
|
|
212
|
+
`get_snowflake_connection()` always has a working credential inside the
|
|
213
|
+
container. So a workload can still write data to a Snowflake table or a stage it
|
|
214
|
+
can reach even under `Egress.only()`. Treat this as *"which arbitrary
|
|
215
|
+
internet hosts may I reach"*, **not** *"data cannot leave"*: it is not a
|
|
216
|
+
data-exfiltration boundary against a Snowflake-reachable destination.
|
|
217
|
+
|
|
218
|
+
Reachability is also not the same as credential scope — a granted host does not
|
|
219
|
+
get a credential, and a `Secret`'s ``host`` is itself a grant, not a
|
|
220
|
+
restriction. See *Reachability is not credential scope* above and the
|
|
221
|
+
``Secret`` docs.
|
|
222
|
+
"""
|
|
223
|
+
# New spelling deliberately: only() must not emit a DeprecationWarning at a
|
|
224
|
+
# caller who never touched the old name.
|
|
225
|
+
return Egress(allow_default_egress=False)
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def default_egress_allowed(self) -> bool | None:
|
|
229
|
+
"""The effective tri-state, whichever spelling the caller used.
|
|
230
|
+
|
|
231
|
+
Read this rather than either field: a caller who set only the new name must not
|
|
232
|
+
look "unset" to logic that checks the old one, which is the bug a plain alias
|
|
233
|
+
would introduce.
|
|
234
|
+
"""
|
|
235
|
+
if self.allow_default_egress is not None:
|
|
236
|
+
return self.allow_default_egress
|
|
237
|
+
return self.allow_internet
|
|
238
|
+
|
|
239
|
+
def to_wire(self) -> dict[str, object]:
|
|
240
|
+
"""This scope as the ``egress`` request body, without secrets.
|
|
241
|
+
|
|
242
|
+
Use `compile_egress()` to fold a sandbox's ``secrets=`` in.
|
|
243
|
+
"""
|
|
244
|
+
body: dict[str, object] = {}
|
|
245
|
+
effective = self.default_egress_allowed
|
|
246
|
+
if effective is not None:
|
|
247
|
+
# Sent even when False: the platform reads an absent key as its permissive
|
|
248
|
+
# default, so omitting it would silently discard a request to close egress.
|
|
249
|
+
# The key stays allow_internet — the rename is client-side only.
|
|
250
|
+
body[_DEFAULT_EGRESS_WIRE_KEY] = effective
|
|
251
|
+
# Neither group flag is sent: the platform is removing the GitHub/dbt flag and already
|
|
252
|
+
# reserved the one allow_pypi fed, so either would describe a grant that never happens.
|
|
253
|
+
# Both stay accepted on the way in (see the field comments).
|
|
254
|
+
# No host-list key is ever sent: allowed_egress_hosts was removed from Egress (a
|
|
255
|
+
# caller-supplied host list is retired platform-side). The raw egress={...} dict
|
|
256
|
+
# path still refuses the wire spellings in compile_egress().
|
|
257
|
+
if self.external_access_integrations:
|
|
258
|
+
body["external_access_integrations"] = self._validated_eais()
|
|
259
|
+
return body
|
|
260
|
+
|
|
261
|
+
def _validated_eais(self) -> list[str]:
|
|
262
|
+
"""EAI names, shape-checked and deduped the way the platform dedupes them.
|
|
263
|
+
|
|
264
|
+
The platform compares EAI names *unqualified*, so ``DB.SCHEMA.X`` and ``X``
|
|
265
|
+
are the same integration and a repeat is a hard error there. Rejecting it
|
|
266
|
+
here gives a clearer message than the remote one.
|
|
267
|
+
"""
|
|
268
|
+
out: list[str] = []
|
|
269
|
+
seen: dict[str, str] = {}
|
|
270
|
+
for raw in self.external_access_integrations:
|
|
271
|
+
name = raw.strip()
|
|
272
|
+
if not name:
|
|
273
|
+
raise SandboxError("external_access_integrations contains an empty name")
|
|
274
|
+
if any(c in name for c in "/: \t"):
|
|
275
|
+
raise SandboxError(
|
|
276
|
+
f"external access integration {name!r} must be a bare name — "
|
|
277
|
+
f"'MY_EAI' or 'DB.SCHEMA.MY_EAI', with no scheme, port, or path"
|
|
278
|
+
)
|
|
279
|
+
key = name.rsplit(".", 1)[-1].upper()
|
|
280
|
+
if key in seen:
|
|
281
|
+
raise SandboxError(
|
|
282
|
+
f"external access integrations {seen[key]!r} and {name!r} are the same "
|
|
283
|
+
f"integration: names are compared unqualified, so 'DB.SCHEMA.X' and 'X' "
|
|
284
|
+
f"collide"
|
|
285
|
+
)
|
|
286
|
+
seen[key] = name
|
|
287
|
+
out.append(name)
|
|
288
|
+
return out
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def compile_egress(
|
|
292
|
+
egress: Egress | Mapping[str, object] | None,
|
|
293
|
+
secrets: Sequence[Secret | Mapping[str, object]] = (),
|
|
294
|
+
) -> dict[str, object] | None:
|
|
295
|
+
"""Fold *secrets* into *egress* to build the request's ``egress`` object.
|
|
296
|
+
|
|
297
|
+
Secrets are authored flat but nest on the wire, since ``egress.secrets`` is a
|
|
298
|
+
field of Snowflake's egress config. A plain dict is accepted for the imperative
|
|
299
|
+
path (and may already carry ``secrets``).
|
|
300
|
+
|
|
301
|
+
Passing ``egress=None`` means "use the defaults" rather than "send nothing", though
|
|
302
|
+
the two now coincide: the only meaningful field is the default-egress tri-state, whose
|
|
303
|
+
default is the platform's own, so a default `Egress` serialises to nothing. A dict is
|
|
304
|
+
taken as authored, since that path is the raw-wire escape hatch — except that
|
|
305
|
+
``allow_default_egress`` is translated to the wire key, because a raw-dict caller who
|
|
306
|
+
used the name the docs now teach would otherwise have it silently ignored.
|
|
307
|
+
"""
|
|
308
|
+
if isinstance(egress, Egress):
|
|
309
|
+
body = egress.to_wire()
|
|
310
|
+
elif egress is None:
|
|
311
|
+
body = Egress().to_wire()
|
|
312
|
+
else:
|
|
313
|
+
body = dict(egress)
|
|
314
|
+
# A raw dict is otherwise passed through verbatim, which would make
|
|
315
|
+
# {"allow_default_egress": False} a silent no-op: the server sees no
|
|
316
|
+
# allow_internet key and applies its PERMISSIVE default, so a caller asking to
|
|
317
|
+
# close egress would get the baseline instead. Translate to the wire key rather
|
|
318
|
+
# than refuse, matching what the typed path accepts.
|
|
319
|
+
if "allow_default_egress" in body:
|
|
320
|
+
renamed = body.pop("allow_default_egress")
|
|
321
|
+
existing = body.get(_DEFAULT_EGRESS_WIRE_KEY)
|
|
322
|
+
if existing is not None and renamed is not None and existing != renamed:
|
|
323
|
+
raise SandboxError(
|
|
324
|
+
f"egress dict sets both {_DEFAULT_EGRESS_WIRE_KEY!r} and "
|
|
325
|
+
f"'allow_default_egress' to different values ({existing!r} vs "
|
|
326
|
+
f"{renamed!r}). They are the same control — pass one."
|
|
327
|
+
)
|
|
328
|
+
if renamed is not None:
|
|
329
|
+
body[_DEFAULT_EGRESS_WIRE_KEY] = renamed
|
|
330
|
+
# Two spellings of the host list in one dict is refused rather than resolved.
|
|
331
|
+
# The normalizing loop below writes into _HOST_LIST_WIRE_KEY, which is also
|
|
332
|
+
# the last key it reads, so an earlier alias used to overwrite a later one's
|
|
333
|
+
# value before that value was ever read: {allowed_egress_hosts: [a],
|
|
334
|
+
# allowed_domains: [b]} silently compiled to [a], and which alias won was an
|
|
335
|
+
# artifact of pop-and-overwrite ordering rather than a precedence anyone
|
|
336
|
+
# chose ('allowed_hosts' beat 'allowed_egress_hosts', which beat
|
|
337
|
+
# 'allowed_domains'). On an egress allowlist, quietly enforcing a narrower
|
|
338
|
+
# host set than the caller wrote is the one outcome worth failing over, so
|
|
339
|
+
# ambiguity is an error and the caller picks a spelling.
|
|
340
|
+
#
|
|
341
|
+
# Only non-empty values count: an alias mapped to [] contributes no hosts, so
|
|
342
|
+
# `{allowed_egress_hosts: [], allowed_domains: [...]}` loses nothing and is a
|
|
343
|
+
# normal result of merging a default with an override.
|
|
344
|
+
present = [k for k in _HOST_LIST_KEYS if k in body and _nonempty_host_value(body[k])]
|
|
345
|
+
if len(present) > 1:
|
|
346
|
+
raise SandboxError(
|
|
347
|
+
f"egress carries more than one spelling of the host list "
|
|
348
|
+
f"({', '.join(repr(k) for k in present)}), and they would not all be "
|
|
349
|
+
f"applied. Pass exactly one — {_HOST_LIST_PARAM!r} is the blessed name "
|
|
350
|
+
f"(the REST wire key stays {_HOST_LIST_WIRE_KEY!r}). If these came from "
|
|
351
|
+
f"different config sources, merge them into one list first."
|
|
352
|
+
)
|
|
353
|
+
# The raw-dict path is the wire escape hatch, so it accepts all three spellings —
|
|
354
|
+
# and therefore has to refuse all three now that the field is retired. Dropping
|
|
355
|
+
# the key silently would be the worst option available: this path exists precisely
|
|
356
|
+
# for callers pinning wire details, who are the least likely to notice a host list
|
|
357
|
+
# quietly vanishing. An empty value is still tolerated and simply removed, so
|
|
358
|
+
# merging a default over an override keeps working.
|
|
359
|
+
for key in _HOST_LIST_KEYS:
|
|
360
|
+
if key in body:
|
|
361
|
+
raw = body.pop(key)
|
|
362
|
+
if _nonempty_host_value(raw):
|
|
363
|
+
raise SandboxError(_retired_host_list_message(key))
|
|
364
|
+
|
|
365
|
+
# `body` is loosely typed (it may be a caller-supplied dict), so narrow the
|
|
366
|
+
# nested secrets list explicitly before iterating it.
|
|
367
|
+
existing = body.pop("secrets", None)
|
|
368
|
+
entries: list[dict[str, object]] = []
|
|
369
|
+
if isinstance(existing, Sequence) and not isinstance(existing, (str, bytes)):
|
|
370
|
+
entries.extend(dict(e) for e in existing if isinstance(e, Mapping))
|
|
371
|
+
for s in secrets:
|
|
372
|
+
entries.append(dict(s.to_wire()) if isinstance(s, Secret) else dict(s))
|
|
373
|
+
|
|
374
|
+
if entries:
|
|
375
|
+
validate_secret_entries(entries)
|
|
376
|
+
# Brokered secrets ride ``egress.secrets``, which is Snowflake's DEPRECATED
|
|
377
|
+
# egress-config secrets field. On a reconstruction (idle-resume / restart /
|
|
378
|
+
# replica reroute) the controller re-launches the app from its stored app
|
|
379
|
+
# record, which carries no egress config at all — so these secrets are
|
|
380
|
+
# dropped and the sandbox comes back without them. That record does have a
|
|
381
|
+
# secrets field of its own, but it is a *file-mount* shape
|
|
382
|
+
# ({secret_name, mount_relative_path}), not the brokered
|
|
383
|
+
# {fqn, allowed_host, env_var} shape, so no reconstruction-surviving
|
|
384
|
+
# brokered-secret path exists server-side yet. The SDK therefore keeps this
|
|
385
|
+
# wire additive and does NOT emit a speculative top-level field, which the
|
|
386
|
+
# service would either ignore or misread as file-mount secrets.
|
|
387
|
+
body["secrets"] = entries
|
|
388
|
+
return body or None
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Exception hierarchy for ``snowflake.sandbox``.
|
|
2
|
+
|
|
3
|
+
All errors derive from ``SandboxError`` so callers can ``except SandboxError``
|
|
4
|
+
to catch any SDK failure. Cancellation is intentionally NOT wrapped —
|
|
5
|
+
``asyncio.CancelledError`` propagates as-is (see ``_transport.py``).
|
|
6
|
+
|
|
7
|
+
These classes are pure-Python and import-cheap (no httpx, no pydantic), so
|
|
8
|
+
``snowflake.sandbox.__init__`` re-exports them eagerly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"SandboxError",
|
|
15
|
+
"SandboxAuthError",
|
|
16
|
+
"SandboxNotFoundError",
|
|
17
|
+
"SandboxNotReadyError",
|
|
18
|
+
"SandboxNotImplementedError",
|
|
19
|
+
"SandboxValidationError",
|
|
20
|
+
"SandboxConflictError",
|
|
21
|
+
"SandboxExecError",
|
|
22
|
+
"SandboxExecTimeoutError",
|
|
23
|
+
"SandboxRateLimitError",
|
|
24
|
+
"SandboxTransportError",
|
|
25
|
+
"SandboxFileTooLargeError",
|
|
26
|
+
"SandboxContractWarning",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SandboxContractWarning(UserWarning):
|
|
31
|
+
"""The server sent something this SDK does not understand.
|
|
32
|
+
|
|
33
|
+
Emitted instead of silently guessing. Unknown JSON keys are ignored on the
|
|
34
|
+
wire in both directions, so an old server answering a new client (or the
|
|
35
|
+
reverse) otherwise produces a *wrong* value with a 200 and no signal at all
|
|
36
|
+
-- a container reported ``ready`` because its real status was unrecognized,
|
|
37
|
+
or a 64g sandbox reported as the 4g default. Warned, never inferred.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _rebuild_sandbox_error(
|
|
42
|
+
cls: type[BaseException], args: tuple[object, ...], state: dict[str, object]
|
|
43
|
+
) -> BaseException:
|
|
44
|
+
"""Reconstruct a `SandboxError` (or subclass) during unpickling.
|
|
45
|
+
|
|
46
|
+
Bypasses ``__init__`` -- restoring ``args`` and instance ``__dict__``
|
|
47
|
+
directly -- so a subclass with **kw-only** constructor arguments round-trips.
|
|
48
|
+
The default ``BaseException`` reducer reconstructs via ``cls(*self.args)``,
|
|
49
|
+
which drops every keyword-only field: ``SandboxExecError``'s required
|
|
50
|
+
``exit_code`` then raises ``TypeError`` on unpickle, and
|
|
51
|
+
``SandboxRateLimitError``'s ``retry_after`` would silently reset to its
|
|
52
|
+
default. See `SandboxError.__reduce__`.
|
|
53
|
+
"""
|
|
54
|
+
obj = cls.__new__(cls)
|
|
55
|
+
obj.args = args
|
|
56
|
+
obj.__dict__.update(state)
|
|
57
|
+
return obj
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SandboxError(Exception):
|
|
61
|
+
"""Base class for every error raised by ``snowflake.sandbox``.
|
|
62
|
+
|
|
63
|
+
``except SandboxError`` catches every SDK-raised error.
|
|
64
|
+
|
|
65
|
+
Remediation: Catch the specific subclass that applies to your situation
|
|
66
|
+
for precise handling, and use ``except SandboxError`` only as a
|
|
67
|
+
catch-all fallback.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __reduce__(
|
|
71
|
+
self,
|
|
72
|
+
) -> tuple[object, ...]:
|
|
73
|
+
# Make every SDK error picklable, including the subclasses whose __init__
|
|
74
|
+
# takes keyword-only arguments (SandboxExecError.exit_code,
|
|
75
|
+
# SandboxRateLimitError.retry_after). The default reducer replays
|
|
76
|
+
# ``cls(*self.args)``, which cannot supply a kw-only field -- a required
|
|
77
|
+
# one (exit_code) then raises TypeError on unpickle, breaking any transport
|
|
78
|
+
# that pickles exceptions across a process boundary (ProcessPool, Celery,
|
|
79
|
+
# Ray, Airflow). Rebuild via __new__ + state instead so all fields survive.
|
|
80
|
+
return (_rebuild_sandbox_error, (type(self), self.args, self.__dict__.copy()))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SandboxAuthError(SandboxError):
|
|
84
|
+
"""HTTP 401/403 from the Cortex Sandboxes service.
|
|
85
|
+
|
|
86
|
+
Remediation: Verify your Snowflake connection is configured and
|
|
87
|
+
authenticated, and that your role has permission to create sandboxes.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SandboxNotFoundError(SandboxError):
|
|
92
|
+
"""HTTP 404 — container or resource missing.
|
|
93
|
+
|
|
94
|
+
Remediation: The sandbox id is invalid or the sandbox was already
|
|
95
|
+
terminated. Re-create it.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SandboxNotReadyError(SandboxError):
|
|
100
|
+
"""Container not ready: HTTP 409 cold-start exhausted, or async create timed out.
|
|
101
|
+
|
|
102
|
+
Raised either when the server returns HTTP 409 after the retry budget is
|
|
103
|
+
exhausted (cold-start), or when ``Sandbox.create()`` / ``wait_until_ready()``
|
|
104
|
+
polling does not see the container become live within the timeout window.
|
|
105
|
+
|
|
106
|
+
Remediation: Wait a moment and retry, or increase the ``timeout=`` passed to
|
|
107
|
+
``wait_until_ready()``.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SandboxValidationError(SandboxError):
|
|
112
|
+
"""HTTP 400 — the request parameters are invalid.
|
|
113
|
+
|
|
114
|
+
The server rejected the request because something in it was malformed or
|
|
115
|
+
violated a constraint. Common causes include: a reserved environment variable
|
|
116
|
+
name (SNOWFLAKE_*, SANDBOX_*), an invalid image name, a malformed egress
|
|
117
|
+
configuration, or a missing required field.
|
|
118
|
+
|
|
119
|
+
Remediation: Check the error message for which parameter is invalid.
|
|
120
|
+
The message usually includes the specific field and why it was rejected.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SandboxConflictError(SandboxError):
|
|
125
|
+
"""HTTP 409 — the resource already exists or is in a conflicting state.
|
|
126
|
+
|
|
127
|
+
A name collision, a duplicate create, or an operation that conflicts with
|
|
128
|
+
the resource's current state. Unlike ``SandboxNotReadyError`` (which is a
|
|
129
|
+
transient cold-start condition the SDK retries internally), this is a
|
|
130
|
+
genuine conflict that won't resolve on retry.
|
|
131
|
+
|
|
132
|
+
Remediation: Use a unique name, connect to the existing sandbox instead
|
|
133
|
+
of creating a new one, or wait for the conflicting operation to complete.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class SandboxNotImplementedError(SandboxError):
|
|
138
|
+
"""A surfaced SDK method has no backend and will not until one lands.
|
|
139
|
+
|
|
140
|
+
Raised by the not-yet-implemented stubs (``snapshot_*``,
|
|
141
|
+
``mount_image``/``unmount_image``, ``tunnels``, ``create_connect_token``) so an
|
|
142
|
+
unsupported call is *distinguishable* from an operational failure — a bare
|
|
143
|
+
``SandboxError`` conflates "not built" with "transiently broke", and the
|
|
144
|
+
base-class docstring tells callers to ``except SandboxError``. This is a
|
|
145
|
+
permanent "not supported here", not a retryable condition: catching it and
|
|
146
|
+
retrying loops forever against a feature that is never coming.
|
|
147
|
+
|
|
148
|
+
It subclasses ``SandboxError`` so existing ``except SandboxError`` handlers
|
|
149
|
+
still catch it. The alternative — deriving from the stdlib
|
|
150
|
+
``NotImplementedError``, outside the ``SandboxError`` tree — would stop a
|
|
151
|
+
broad ``except SandboxError: retry()`` from ever catching an unimplemented
|
|
152
|
+
call, but at the cost of escaping the catch-all handlers that today expect
|
|
153
|
+
every SDK error under ``SandboxError``.
|
|
154
|
+
|
|
155
|
+
Remediation: Remove the unsupported parameter or feature flag from your
|
|
156
|
+
code. This is not a transient failure — retrying will not help.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SandboxFileTooLargeError(SandboxError):
|
|
161
|
+
"""A file transfer exceeded the size the platform will carry.
|
|
162
|
+
|
|
163
|
+
Raised locally before the request when the caller's file is over the limit, and
|
|
164
|
+
on a 413 from the server. The ceiling is not the sandbox's: Snowflake's REST
|
|
165
|
+
front end caps request bodies well below any interesting file size, so bulk data
|
|
166
|
+
goes through a mounted stage rather than this path.
|
|
167
|
+
|
|
168
|
+
Remediation: Split the file into smaller chunks or upload via a Snowflake
|
|
169
|
+
stage and mount it instead.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class SandboxExecError(SandboxError):
|
|
174
|
+
"""Non-zero exit from ``Sandbox.exec``. Carries stdout/stderr/exit_code.
|
|
175
|
+
|
|
176
|
+
Remediation: Inspect ``exception.exit_code``, ``exception.stdout``, and
|
|
177
|
+
``exception.stderr`` to diagnose the command failure.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
message: str,
|
|
183
|
+
*,
|
|
184
|
+
exit_code: int,
|
|
185
|
+
stdout: str = "",
|
|
186
|
+
stderr: str = "",
|
|
187
|
+
) -> None:
|
|
188
|
+
super().__init__(message)
|
|
189
|
+
self.exit_code = exit_code
|
|
190
|
+
self.stdout = stdout
|
|
191
|
+
self.stderr = stderr
|
|
192
|
+
|
|
193
|
+
def __str__(self) -> str:
|
|
194
|
+
parts = [super().__str__()]
|
|
195
|
+
if self.stderr:
|
|
196
|
+
parts.append(f"stderr: {self.stderr[:2000]}")
|
|
197
|
+
elif self.stdout:
|
|
198
|
+
parts.append(f"stdout: {self.stdout[:2000]}")
|
|
199
|
+
return "\n".join(parts)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SandboxExecTimeoutError(SandboxError):
|
|
203
|
+
"""The server enforced its exec deadline, killing the command.
|
|
204
|
+
|
|
205
|
+
``stdout``/``stderr`` carry the partial output the server returns on a timeout --
|
|
206
|
+
how a caller tells "the work started" from "it never did". All three fields are
|
|
207
|
+
optional: the transport also raises this for a 408 with no parsed body.
|
|
208
|
+
|
|
209
|
+
Remediation: inspect ``exception.stdout``/``exception.stderr`` to see how far the
|
|
210
|
+
command got, then raise ``timeout=``. For a long-lived process ``exec`` is the
|
|
211
|
+
wrong tool -- see ``Sandbox.create(command=...)``.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(
|
|
215
|
+
self,
|
|
216
|
+
message: str,
|
|
217
|
+
*,
|
|
218
|
+
exit_code: int | None = None,
|
|
219
|
+
stdout: str = "",
|
|
220
|
+
stderr: str = "",
|
|
221
|
+
) -> None:
|
|
222
|
+
super().__init__(message)
|
|
223
|
+
self.exit_code = exit_code
|
|
224
|
+
self.stdout = stdout
|
|
225
|
+
self.stderr = stderr
|
|
226
|
+
|
|
227
|
+
def __str__(self) -> str:
|
|
228
|
+
parts = [super().__str__()]
|
|
229
|
+
if self.stderr:
|
|
230
|
+
parts.append(f"stderr: {self.stderr[:2000]}")
|
|
231
|
+
elif self.stdout:
|
|
232
|
+
parts.append(f"stdout: {self.stdout[:2000]}")
|
|
233
|
+
return "\n".join(parts)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class SandboxRateLimitError(SandboxError):
|
|
237
|
+
"""HTTP 429 after the SDK retry budget.
|
|
238
|
+
|
|
239
|
+
Remediation: Check ``exception.retry_after`` for the recommended wait
|
|
240
|
+
time before retrying, or reduce request concurrency.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(self, message: str, *, retry_after: float | None = None) -> None:
|
|
244
|
+
super().__init__(message)
|
|
245
|
+
self.retry_after = retry_after
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class SandboxTransportError(SandboxError):
|
|
249
|
+
"""5xx, DNS, connection, or read-timeout failures after retries.
|
|
250
|
+
|
|
251
|
+
Remediation: Check network connectivity and Snowflake service status,
|
|
252
|
+
then retry.
|
|
253
|
+
"""
|