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,666 @@
|
|
|
1
|
+
"""``snow sandbox shell`` / ``ssh`` — run a command, open a terminal, or attach.
|
|
2
|
+
|
|
3
|
+
Split out of ``commands.py`` unchanged: the shell command callback, its
|
|
4
|
+
create-retry policy, and the streaming/PTY helpers it drives. ``commands``
|
|
5
|
+
imports the callback and registers it onto the shared ``app`` (as both ``shell``
|
|
6
|
+
and ``ssh``); the shared helpers this reads come from ``_common``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from snowflake.cli_sandbox._common import (
|
|
19
|
+
_CONN_HELP,
|
|
20
|
+
_ENV_HELP,
|
|
21
|
+
_USE_SNOW_CONN_HELP,
|
|
22
|
+
_apply_connection,
|
|
23
|
+
_parse_env,
|
|
24
|
+
_resolve_sandbox_id,
|
|
25
|
+
)
|
|
26
|
+
from snowflake.cli_sandbox._egress_flags import (
|
|
27
|
+
EAI_HELP,
|
|
28
|
+
NO_DEFAULT_EGRESS_HELP,
|
|
29
|
+
SECRET_HELP,
|
|
30
|
+
build_egress,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from snowflake.sandbox.client import AsyncSandbox
|
|
35
|
+
from snowflake.sandbox.egress import Egress
|
|
36
|
+
from snowflake.sandbox.secret import Secret
|
|
37
|
+
|
|
38
|
+
_SHELL_HELP = """Run a command, open a terminal, or attach to a running sandbox.
|
|
39
|
+
|
|
40
|
+
What it does depends on how you invoke it:
|
|
41
|
+
* --cmd, no SANDBOX_ID -> create a fresh ephemeral sandbox, run the command
|
|
42
|
+
(streaming stdout/stderr split, exiting with its code), then tear it down.
|
|
43
|
+
--keep leaves it running and prints its name.
|
|
44
|
+
* --cmd + SANDBOX_ID -> run the command once in that existing sandbox, which
|
|
45
|
+
is left running (not yours to destroy).
|
|
46
|
+
* no --cmd, no SANDBOX_ID -> open an interactive terminal: attach to the one
|
|
47
|
+
running sandbox, or pick from a list when there are several. When NONE are
|
|
48
|
+
running, spin up a fresh ephemeral box interactively (rather than dead-end on
|
|
49
|
+
an empty picker). Pass --new to always create a fresh box even when others are
|
|
50
|
+
running, or a create option (e.g. --image) to size the fresh box.
|
|
51
|
+
* no --cmd + SANDBOX_ID -> open an interactive terminal in that sandbox.
|
|
52
|
+
|
|
53
|
+
The create-sizing options (--new/--image/--memory/--cpu/--keep) apply
|
|
54
|
+
only when creating a fresh sandbox; passing them with a SANDBOX_ID is an error
|
|
55
|
+
rather than a silent no-op.
|
|
56
|
+
|
|
57
|
+
Note:
|
|
58
|
+
The interactive terminal is a PTY over the ordinary Snowflake REST path, not
|
|
59
|
+
the SSH protocol -- no server, no port 22, no keys, no scp. --no-sanitize
|
|
60
|
+
passes the sandbox's output through byte-exact, including clipboard-write
|
|
61
|
+
sequences (see Shell.attach's trust note); it affects the interactive
|
|
62
|
+
terminal only.
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
snow sandbox shell --cmd "python -c 'print(40 + 2)'" # fresh box: run, tear down
|
|
66
|
+
snow sandbox shell --cmd "python train.py" --memory 16g
|
|
67
|
+
snow sandbox shell # pick a running box (or make one if none)
|
|
68
|
+
snow sandbox shell --new # always a fresh box, interactive
|
|
69
|
+
snow sandbox shell --image sandbox-base # fresh box, interactive
|
|
70
|
+
snow sandbox shell cntr_9f2a1b # attach to an existing box
|
|
71
|
+
snow sandbox shell cntr_9f2a1b --cmd "ls -la" # run once in an existing box
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# How many times to (re)create a fresh sandbox when a create hits a transient
|
|
76
|
+
# scheduling miss, and how long to pause between attempts (a fresh create reschedules,
|
|
77
|
+
# so a short pause lets capacity free up).
|
|
78
|
+
_CREATE_RETRY_ATTEMPTS = 3
|
|
79
|
+
_CREATE_RETRY_BACKOFF_S = 2.0
|
|
80
|
+
|
|
81
|
+
# Substrings that mark a create / first-use failure worth repeating with a NEW sandbox.
|
|
82
|
+
# The CNG scheduler can momentarily have no placement ("no hosts match the
|
|
83
|
+
# requirements"); because the server mints a brand-new sandbox per create and refuses
|
|
84
|
+
# to re-drive a failed one, the only correct retry is a fresh create. These are the
|
|
85
|
+
# FAST scheduling / not-yet-routable signatures (each fails in ~seconds, so a bounded
|
|
86
|
+
# retry is cheap even when the cause turns out to be permanent, e.g. a cpu/memory
|
|
87
|
+
# request larger than any host). Deliberately NOT included:
|
|
88
|
+
# * a create TIMEOUT ("did not become ready") — that is the 300s strict-wait giving
|
|
89
|
+
# up on a hung StartApp; retrying it would stack 5-minute waits into a many-minute
|
|
90
|
+
# hang, and a hang is not the fast scheduling-miss this targets.
|
|
91
|
+
# * the generic "container create failed" prefix — it wraps ANY reason (e.g. an
|
|
92
|
+
# image-pull/auth failure), so matching it would retry deterministic errors; the
|
|
93
|
+
# transient reasons below already match inside such a message when it IS transient.
|
|
94
|
+
# Anything not listed is surfaced on the first attempt rather than hammered.
|
|
95
|
+
_TRANSIENT_CREATE_MARKERS = (
|
|
96
|
+
"no hosts match the requirements",
|
|
97
|
+
"scheduling failed",
|
|
98
|
+
"failed to reach sandbox exec stream",
|
|
99
|
+
"container not found",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _looks_transient_create_error(exc: BaseException) -> bool:
|
|
104
|
+
"""Whether *exc* from a create (or the first exec on a fresh sandbox) is the kind of
|
|
105
|
+
transient scheduling failure that a repeat create is likely to clear."""
|
|
106
|
+
msg = str(exc).lower()
|
|
107
|
+
return any(marker in msg for marker in _TRANSIENT_CREATE_MARKERS)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _wants_fresh_sandbox(
|
|
111
|
+
*,
|
|
112
|
+
new: bool,
|
|
113
|
+
cmd: str | None,
|
|
114
|
+
image: str | None,
|
|
115
|
+
memory: str | None,
|
|
116
|
+
cpu: float | None,
|
|
117
|
+
keep: bool,
|
|
118
|
+
env_overrides: dict[str, str],
|
|
119
|
+
) -> bool:
|
|
120
|
+
"""True when the invocation asks to CREATE a sandbox rather than attach/pick.
|
|
121
|
+
|
|
122
|
+
An explicit ``--new``, a ``--cmd`` to run, any create-sizing option, or
|
|
123
|
+
``--keep`` all force the create path. A bare ``shell``/``ssh`` with none of
|
|
124
|
+
these keeps the attach-or-pick behavior (and won't eagerly bill a fresh box).
|
|
125
|
+
"""
|
|
126
|
+
return (
|
|
127
|
+
new
|
|
128
|
+
or cmd is not None
|
|
129
|
+
or image is not None
|
|
130
|
+
or memory is not None
|
|
131
|
+
or cpu is not None
|
|
132
|
+
or keep
|
|
133
|
+
or bool(env_overrides)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _reject_incompatible_attach_flags(
|
|
138
|
+
*,
|
|
139
|
+
sandbox_id: str,
|
|
140
|
+
new: bool,
|
|
141
|
+
image: str | None,
|
|
142
|
+
eai: list[str] | None = None,
|
|
143
|
+
secret: list[str] | None = None,
|
|
144
|
+
no_default_egress: bool = False,
|
|
145
|
+
memory: str | None,
|
|
146
|
+
cpu: float | None,
|
|
147
|
+
keep: bool,
|
|
148
|
+
env_overrides: dict[str, str],
|
|
149
|
+
cmd: str | None,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Refuse create-sizing knobs when attaching to an existing sandbox.
|
|
152
|
+
|
|
153
|
+
They describe a sandbox we would make; against one that already exists they
|
|
154
|
+
cannot take effect, so we reject loudly rather than accept-and-ignore (the
|
|
155
|
+
silent-no-op trap this SDK keeps hitting).
|
|
156
|
+
"""
|
|
157
|
+
create_only = [
|
|
158
|
+
name
|
|
159
|
+
for name, given in (
|
|
160
|
+
("--new", new),
|
|
161
|
+
("--image", image is not None),
|
|
162
|
+
("--memory", memory is not None),
|
|
163
|
+
("--cpu", cpu is not None),
|
|
164
|
+
("--keep", keep),
|
|
165
|
+
# Egress is fixed at create, so these cannot apply to a box that exists.
|
|
166
|
+
("--eai", bool(eai)),
|
|
167
|
+
("--secret", bool(secret)),
|
|
168
|
+
("--no-default-egress", no_default_egress),
|
|
169
|
+
)
|
|
170
|
+
if given
|
|
171
|
+
]
|
|
172
|
+
if create_only:
|
|
173
|
+
raise typer.BadParameter(
|
|
174
|
+
f"{', '.join(create_only)} only appl{'ies' if len(create_only) == 1 else 'y'} "
|
|
175
|
+
f"when creating a fresh sandbox; {sandbox_id} already exists."
|
|
176
|
+
)
|
|
177
|
+
if env_overrides and cmd is None:
|
|
178
|
+
raise typer.BadParameter(
|
|
179
|
+
"--env needs --cmd when attaching: env is injected per command "
|
|
180
|
+
"run, and an interactive shell in an existing sandbox cannot take it."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _try_interactive_picker(
|
|
185
|
+
*, sandbox_id: str | None, cwd: str | None, no_sanitize: bool, quiet: bool
|
|
186
|
+
) -> bool:
|
|
187
|
+
"""Attach to *sandbox_id*, or pick from the running set (delegated to ``ssh``).
|
|
188
|
+
|
|
189
|
+
Returns True if it handled the session (the caller should return); False to
|
|
190
|
+
fall through to creating a fresh ephemeral sandbox — the case where no id was
|
|
191
|
+
given and nothing is running, so there is nothing to pick. Delegates the
|
|
192
|
+
terminal wiring and exit codes to the SDK module command so they cannot drift.
|
|
193
|
+
"""
|
|
194
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
195
|
+
|
|
196
|
+
use_picker = True
|
|
197
|
+
if not sandbox_id:
|
|
198
|
+
# Only the no-id case can be empty; with an id we always attach. Probe the
|
|
199
|
+
# running set cheaply -- one running box is enough. A probe failure is not a
|
|
200
|
+
# reason to skip the proven path, so fall back to opening the picker.
|
|
201
|
+
from snowflake.cli_sandbox._adapter import sdk_connection
|
|
202
|
+
from snowflake.sandbox.client import list_sandboxes
|
|
203
|
+
|
|
204
|
+
async def _any_running() -> bool:
|
|
205
|
+
async for _sb in list_sandboxes(connection=sdk_connection()):
|
|
206
|
+
return True
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
use_picker = asyncio.run(_any_running())
|
|
211
|
+
except SandboxError:
|
|
212
|
+
use_picker = True
|
|
213
|
+
|
|
214
|
+
if not use_picker:
|
|
215
|
+
_emit_status("No running sandboxes -- creating a fresh one...", quiet=quiet)
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
from snowflake.sandbox.__main__ import main as sandbox_main
|
|
219
|
+
|
|
220
|
+
argv = ["ssh"]
|
|
221
|
+
if sandbox_id:
|
|
222
|
+
argv.append(sandbox_id)
|
|
223
|
+
if cwd:
|
|
224
|
+
argv += ["--cwd", cwd]
|
|
225
|
+
if no_sanitize:
|
|
226
|
+
argv.append("--no-sanitize")
|
|
227
|
+
try:
|
|
228
|
+
code = sandbox_main(argv)
|
|
229
|
+
except SandboxError as exc:
|
|
230
|
+
typer.echo(f"error: {exc}", err=True)
|
|
231
|
+
raise typer.Exit(1) from exc
|
|
232
|
+
if code:
|
|
233
|
+
raise typer.Exit(code)
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True)
|
|
238
|
+
class _ShellPlan:
|
|
239
|
+
"""The resolved shell invocation, after flag parsing and validation.
|
|
240
|
+
|
|
241
|
+
Threads the settled request from `_shell_command` (which owns the typer
|
|
242
|
+
signature) to the async workers `_run_shell` / `_use_sandbox`, so the two
|
|
243
|
+
stay module-level and independently testable rather than closures capturing
|
|
244
|
+
the command's many flags.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
attaching: bool
|
|
248
|
+
sandbox_id: str | None
|
|
249
|
+
cmd: str | None
|
|
250
|
+
cwd: str | None
|
|
251
|
+
no_sanitize: bool
|
|
252
|
+
quiet: bool
|
|
253
|
+
keep: bool
|
|
254
|
+
image: str | None
|
|
255
|
+
egress: Egress | None
|
|
256
|
+
secrets: tuple[Secret, ...]
|
|
257
|
+
memory: str | None
|
|
258
|
+
cpu: float | None
|
|
259
|
+
env_overrides: dict[str, str]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def _use_sandbox(sb: Any, plan: _ShellPlan) -> int:
|
|
263
|
+
"""Run the command in *sb* (streamed) or open an interactive PTY."""
|
|
264
|
+
if plan.cmd is not None:
|
|
265
|
+
# A blank line sets the status block apart from the command's own output
|
|
266
|
+
# that follows, so the two never read as one stream.
|
|
267
|
+
if not plan.quiet:
|
|
268
|
+
typer.echo("", err=True)
|
|
269
|
+
# env rides the exec only when attaching; a fresh sandbox already carries
|
|
270
|
+
# it from create.
|
|
271
|
+
return await _exec_streaming(
|
|
272
|
+
sb, plan.cmd, plan.cwd, plan.env_overrides if plan.attaching else None
|
|
273
|
+
)
|
|
274
|
+
return await _attach_pty(sb, cwd=plan.cwd, sanitize=not plan.no_sanitize, quiet=plan.quiet)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def _run_shell(plan: _ShellPlan) -> int:
|
|
278
|
+
from snowflake.cli_sandbox._adapter import sdk_connection
|
|
279
|
+
from snowflake.sandbox.client import get_sandbox
|
|
280
|
+
|
|
281
|
+
if plan.attaching:
|
|
282
|
+
# An attached sandbox already exists -- no create, so nothing to retry, and
|
|
283
|
+
# its lifecycle is not ours to end.
|
|
284
|
+
sb = await get_sandbox(
|
|
285
|
+
plan.sandbox_id, # type: ignore[arg-type] # attaching => not None
|
|
286
|
+
connection=sdk_connection(),
|
|
287
|
+
)
|
|
288
|
+
if plan.cmd is not None:
|
|
289
|
+
_emit_status(f"Running command in {sb.name or sb.id}...", quiet=plan.quiet)
|
|
290
|
+
return await _use_sandbox(sb, plan)
|
|
291
|
+
|
|
292
|
+
async def _use(sb: AsyncSandbox) -> int:
|
|
293
|
+
return await _use_sandbox(sb, plan)
|
|
294
|
+
|
|
295
|
+
return await _run_on_fresh_sandbox(
|
|
296
|
+
use=_use,
|
|
297
|
+
keep=plan.keep,
|
|
298
|
+
quiet=plan.quiet,
|
|
299
|
+
# The "ready" line only makes sense before a command runs; an interactive
|
|
300
|
+
# attach prints its own "Connected to..." instead.
|
|
301
|
+
announce_ready=plan.cmd is not None,
|
|
302
|
+
keep_notice=True,
|
|
303
|
+
image=plan.image,
|
|
304
|
+
memory=plan.memory,
|
|
305
|
+
cpu=plan.cpu,
|
|
306
|
+
env=plan.env_overrides or None,
|
|
307
|
+
egress=plan.egress,
|
|
308
|
+
secrets=plan.secrets,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def _run_on_fresh_sandbox(
|
|
313
|
+
*,
|
|
314
|
+
use: Callable[[AsyncSandbox], Awaitable[int]],
|
|
315
|
+
keep: bool,
|
|
316
|
+
quiet: bool,
|
|
317
|
+
announce_ready: bool = False,
|
|
318
|
+
keep_notice: bool = False,
|
|
319
|
+
image: str | None = None,
|
|
320
|
+
memory: str | None = None,
|
|
321
|
+
cpu: float | None = None,
|
|
322
|
+
env: Mapping[str, str] | None = None,
|
|
323
|
+
name: str | None = None,
|
|
324
|
+
command: Sequence[str] | None = None,
|
|
325
|
+
idle_suspend: str | None = None,
|
|
326
|
+
egress: Egress | None = None,
|
|
327
|
+
secrets: tuple[Secret, ...] = (),
|
|
328
|
+
) -> int:
|
|
329
|
+
"""Create a fresh sandbox, run ``use(sandbox)`` on it, and return its result —
|
|
330
|
+
the shared "spin up, run, (optionally) tear down" path behind ``snow sandbox
|
|
331
|
+
shell`` and ``snow sandbox run``, so the startup status lines and the transient
|
|
332
|
+
retry live in one place.
|
|
333
|
+
|
|
334
|
+
Emits the "Spinning up..." status. A create can hit a transient CNG scheduling
|
|
335
|
+
miss ("no hosts match the requirements"), and because the server marks a sandbox
|
|
336
|
+
ready before StartApp finishes it can also surface a beat later as a 502 on the
|
|
337
|
+
first use; either way the fix is a brand-new sandbox (the server refuses to
|
|
338
|
+
re-drive a failed one), so both are retried with a fresh create. A real error is
|
|
339
|
+
surfaced on the first attempt.
|
|
340
|
+
|
|
341
|
+
Tears the sandbox down afterward unless ``keep``. With ``keep`` and
|
|
342
|
+
``keep_notice`` it prints the surviving sandbox's id so the caller can find it
|
|
343
|
+
(``shell --keep``); a caller that prints its own handle passes ``keep_notice``
|
|
344
|
+
False (``run --detach``). ``command``/``idle_suspend``/``name`` are forwarded
|
|
345
|
+
to create for the long-running (``--detach``) callers; the ephemeral callers
|
|
346
|
+
leave them unset.
|
|
347
|
+
"""
|
|
348
|
+
from snowflake.cli_sandbox._adapter import sdk_connection
|
|
349
|
+
from snowflake.sandbox.client import AsyncSandbox
|
|
350
|
+
from snowflake.sandbox.egress import Egress # noqa: F401
|
|
351
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
352
|
+
from snowflake.sandbox.secret import Secret # noqa: F401
|
|
353
|
+
|
|
354
|
+
last_exc: SandboxError | None = None
|
|
355
|
+
for attempt in range(1, _CREATE_RETRY_ATTEMPTS + 1):
|
|
356
|
+
# Creating a sandbox pulls the image and boots the container, which takes a
|
|
357
|
+
# few seconds with no output -- say so, so it never looks hung.
|
|
358
|
+
_emit_status(f"Spinning up sandbox ({image or 'default image'})...", quiet=quiet)
|
|
359
|
+
created: AsyncSandbox | None = None
|
|
360
|
+
retrying = False
|
|
361
|
+
handed_off = False
|
|
362
|
+
try:
|
|
363
|
+
created = await AsyncSandbox.create(
|
|
364
|
+
# Empty string -> the deployment's default runtime image, resolved
|
|
365
|
+
# server-side. Never hardcode a runtime: the fleet default moves, and
|
|
366
|
+
# a pinned name spins up a stale/unavailable image (its server 502s).
|
|
367
|
+
image=image or "",
|
|
368
|
+
memory=memory or "4g", # type: ignore[arg-type] # validated server-side
|
|
369
|
+
cpu=cpu,
|
|
370
|
+
env=env or None,
|
|
371
|
+
name=name,
|
|
372
|
+
command=command,
|
|
373
|
+
idle_suspend=idle_suspend,
|
|
374
|
+
# Omitted entirely when unset so the create body is byte-identical to
|
|
375
|
+
# what it was before these flags existed.
|
|
376
|
+
egress=egress,
|
|
377
|
+
secrets=list(secrets) or None,
|
|
378
|
+
connection=sdk_connection(),
|
|
379
|
+
)
|
|
380
|
+
if announce_ready:
|
|
381
|
+
_emit_status(f"Sandbox {created.name or created.id} ready", quiet=quiet)
|
|
382
|
+
result = await use(created)
|
|
383
|
+
handed_off = True # `use` returned, so a `keep` sandbox is the caller's now
|
|
384
|
+
return result
|
|
385
|
+
except SandboxError as exc:
|
|
386
|
+
last_exc = exc
|
|
387
|
+
if not _looks_transient_create_error(exc):
|
|
388
|
+
raise # a real error (bad image, invalid request): surface it now
|
|
389
|
+
if attempt >= _CREATE_RETRY_ATTEMPTS:
|
|
390
|
+
break # exhausted: fall through to the capacity message below
|
|
391
|
+
retrying = True
|
|
392
|
+
_emit_status(
|
|
393
|
+
f"transient scheduling error ({exc}); retrying "
|
|
394
|
+
f"[{attempt}/{_CREATE_RETRY_ATTEMPTS - 1}]...",
|
|
395
|
+
quiet=quiet,
|
|
396
|
+
)
|
|
397
|
+
finally:
|
|
398
|
+
# A failed attempt is never "kept"; when we own the lifecycle (not keep)
|
|
399
|
+
# we always tear down. `keep` only earns the sandbox once `use` *returned*:
|
|
400
|
+
# if it raised, nobody was ever handed the id, so keeping it would leave a
|
|
401
|
+
# billed, running, unnamed-to-the-user box -- and burn a `--name` so the
|
|
402
|
+
# obvious retry 409s. Best effort: a failed teardown must not mask the use
|
|
403
|
+
# result (e.g. a command's own exit code).
|
|
404
|
+
if created is not None and (retrying or not keep or not handed_off):
|
|
405
|
+
try:
|
|
406
|
+
await created.terminate()
|
|
407
|
+
except SandboxError as exc:
|
|
408
|
+
typer.echo(
|
|
409
|
+
f"warning: could not terminate {created.name or created.id}: {exc}",
|
|
410
|
+
err=True,
|
|
411
|
+
)
|
|
412
|
+
elif created is not None and keep and handed_off and keep_notice:
|
|
413
|
+
typer.echo(f"sandbox left running: {created.name or created.id}", err=True)
|
|
414
|
+
if retrying:
|
|
415
|
+
await asyncio.sleep(_CREATE_RETRY_BACKOFF_S)
|
|
416
|
+
# Every attempt hit a fast scheduling failure.
|
|
417
|
+
raise SandboxError(
|
|
418
|
+
f"sandbox could not be scheduled after {_CREATE_RETRY_ATTEMPTS} attempts — "
|
|
419
|
+
f"no hosts available (transient capacity, or the requested cpu/memory "
|
|
420
|
+
f"exceeds any host); last error: {last_exc}"
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _shell_command(
|
|
425
|
+
sandbox_id: str | None = typer.Argument(
|
|
426
|
+
None,
|
|
427
|
+
metavar="[SANDBOX_ID]",
|
|
428
|
+
help="Existing sandbox (name or app id) to attach to. Omit to attach to a running one "
|
|
429
|
+
"(picker), or create a fresh ephemeral one if none are running. Use --new to always "
|
|
430
|
+
"create a fresh one.",
|
|
431
|
+
),
|
|
432
|
+
cmd: str | None = typer.Option(
|
|
433
|
+
None,
|
|
434
|
+
"--cmd",
|
|
435
|
+
"--command",
|
|
436
|
+
metavar="COMMAND",
|
|
437
|
+
help="Command to run (streamed, exits with its code). Omit for an interactive "
|
|
438
|
+
"terminal. `--command` is an accepted alias (same flag on `run`).",
|
|
439
|
+
),
|
|
440
|
+
image: str | None = typer.Option(
|
|
441
|
+
None,
|
|
442
|
+
"--image",
|
|
443
|
+
metavar="IMAGE",
|
|
444
|
+
help="Image for a fresh sandbox (default: the deployment's default image). Not valid with SANDBOX_ID.",
|
|
445
|
+
),
|
|
446
|
+
memory: str | None = typer.Option(
|
|
447
|
+
None,
|
|
448
|
+
"--memory",
|
|
449
|
+
metavar="TIER",
|
|
450
|
+
help="Memory tier for a fresh sandbox: 1g/4g/8g/16g/32g/64g (default: 4g). Not valid with SANDBOX_ID.",
|
|
451
|
+
),
|
|
452
|
+
cpu: float | None = typer.Option(
|
|
453
|
+
None,
|
|
454
|
+
"--cpu",
|
|
455
|
+
metavar="CORES",
|
|
456
|
+
help="CPU cores (0.25-8.0) for a fresh sandbox. Not valid with SANDBOX_ID.",
|
|
457
|
+
),
|
|
458
|
+
envs: list[str] | None = typer.Option(
|
|
459
|
+
None,
|
|
460
|
+
"--env",
|
|
461
|
+
"-e",
|
|
462
|
+
metavar="KEY=VAL",
|
|
463
|
+
help=_ENV_HELP,
|
|
464
|
+
),
|
|
465
|
+
eai: list[str] | None = typer.Option(
|
|
466
|
+
None,
|
|
467
|
+
"--eai",
|
|
468
|
+
metavar="NAME",
|
|
469
|
+
help=EAI_HELP,
|
|
470
|
+
),
|
|
471
|
+
secret: list[str] | None = typer.Option(
|
|
472
|
+
None,
|
|
473
|
+
"--secret",
|
|
474
|
+
metavar="ENV_VAR=DB.SCHEMA.NAME@host",
|
|
475
|
+
help=SECRET_HELP,
|
|
476
|
+
),
|
|
477
|
+
no_default_egress: bool = typer.Option(
|
|
478
|
+
False,
|
|
479
|
+
"--no-default-egress",
|
|
480
|
+
help=NO_DEFAULT_EGRESS_HELP,
|
|
481
|
+
),
|
|
482
|
+
keep: bool = typer.Option(
|
|
483
|
+
False,
|
|
484
|
+
"--keep",
|
|
485
|
+
help="Leave a fresh sandbox running on exit (prints its name). Not valid with SANDBOX_ID.",
|
|
486
|
+
),
|
|
487
|
+
new: bool = typer.Option(
|
|
488
|
+
False,
|
|
489
|
+
"--new",
|
|
490
|
+
help="Always create a fresh sandbox, even if some are already running "
|
|
491
|
+
"(skips the attach/picker path). Not valid with SANDBOX_ID.",
|
|
492
|
+
),
|
|
493
|
+
cwd: str | None = typer.Option(
|
|
494
|
+
None,
|
|
495
|
+
"--cwd",
|
|
496
|
+
metavar="DIR",
|
|
497
|
+
help="Working directory for the shell/command.",
|
|
498
|
+
),
|
|
499
|
+
no_sanitize: bool = typer.Option(
|
|
500
|
+
False,
|
|
501
|
+
"--no-sanitize",
|
|
502
|
+
help="Interactive only: pass the sandbox's output through byte-exact. See the trust note.",
|
|
503
|
+
),
|
|
504
|
+
quiet: bool = typer.Option(
|
|
505
|
+
False,
|
|
506
|
+
"--quiet",
|
|
507
|
+
"-q",
|
|
508
|
+
help="Suppress the status lines on stderr (spinning up / ready / running). "
|
|
509
|
+
"The command's own output is unaffected, so `--quiet` is safe for scripting.",
|
|
510
|
+
),
|
|
511
|
+
use_snow_connection: bool = typer.Option(
|
|
512
|
+
True,
|
|
513
|
+
"--use-snow-connection/--no-snow-connection",
|
|
514
|
+
help=_USE_SNOW_CONN_HELP,
|
|
515
|
+
),
|
|
516
|
+
connection: str | None = typer.Option(
|
|
517
|
+
None,
|
|
518
|
+
"--connection",
|
|
519
|
+
"-c",
|
|
520
|
+
help=_CONN_HELP,
|
|
521
|
+
),
|
|
522
|
+
**options: Any,
|
|
523
|
+
) -> None:
|
|
524
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
525
|
+
|
|
526
|
+
# Opening the snow connection is a real login round-trip (a few seconds) and
|
|
527
|
+
# runs before anything else -- say so, so that gap does not look like a hang.
|
|
528
|
+
if use_snow_connection:
|
|
529
|
+
where = f" ({connection})" if connection else ""
|
|
530
|
+
_emit_status(f"Connecting to Snowflake{where}...", quiet=quiet)
|
|
531
|
+
_apply_connection(use_snow_connection, connection=connection)
|
|
532
|
+
|
|
533
|
+
# A reference that isn't a raw app id (cntr_...) is a vanity NAME — the identity
|
|
534
|
+
# `list` shows. Resolve to the app id up front so the attach and ssh-picker paths
|
|
535
|
+
# below work unchanged.
|
|
536
|
+
if sandbox_id:
|
|
537
|
+
sandbox_id = _resolve_sandbox_id(sandbox_id)
|
|
538
|
+
|
|
539
|
+
env_overrides: dict[str, str] = {}
|
|
540
|
+
for raw in envs or []:
|
|
541
|
+
k, v = _parse_env(raw)
|
|
542
|
+
env_overrides[k] = v
|
|
543
|
+
|
|
544
|
+
attaching = sandbox_id is not None
|
|
545
|
+
# "Am I being asked to make a NEW sandbox?" -- an explicit --new, a command to
|
|
546
|
+
# run, or any create-sizing option. Bare `shell`/`ssh` with none of these keeps
|
|
547
|
+
# the long-standing interactive behavior (attach by id, or pick from the running
|
|
548
|
+
# list -- and, when nothing is running, fall through to a fresh ephemeral box)
|
|
549
|
+
# rather than eagerly creating and billing one. Only these force the create path.
|
|
550
|
+
create_intent = _wants_fresh_sandbox(
|
|
551
|
+
new=new,
|
|
552
|
+
cmd=cmd,
|
|
553
|
+
image=image,
|
|
554
|
+
memory=memory,
|
|
555
|
+
cpu=cpu,
|
|
556
|
+
keep=keep,
|
|
557
|
+
env_overrides=env_overrides,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
if attaching:
|
|
561
|
+
_reject_incompatible_attach_flags(
|
|
562
|
+
sandbox_id=sandbox_id, # type: ignore[arg-type] # attaching => not None
|
|
563
|
+
new=new,
|
|
564
|
+
image=image,
|
|
565
|
+
memory=memory,
|
|
566
|
+
cpu=cpu,
|
|
567
|
+
keep=keep,
|
|
568
|
+
env_overrides=env_overrides,
|
|
569
|
+
cmd=cmd,
|
|
570
|
+
eai=eai,
|
|
571
|
+
secret=secret,
|
|
572
|
+
no_default_egress=no_default_egress,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
# Interactive with no create intent == the original behavior, preserved:
|
|
576
|
+
# attach to SANDBOX_ID, or (no id) pick from the running set. The one addition:
|
|
577
|
+
# when no id was given AND nothing is running, there is nothing to pick, so we
|
|
578
|
+
# fall through to create a fresh ephemeral box instead of dead-ending on an
|
|
579
|
+
# empty picker. The attach/picker itself is delegated to the SDK module command
|
|
580
|
+
# so the terminal wiring and exit codes stay identical to what `ssh` has always
|
|
581
|
+
# done and cannot drift.
|
|
582
|
+
# `and` short-circuits, so the picker only runs on the interactive no-create
|
|
583
|
+
# path; it returns True when it handled the session.
|
|
584
|
+
if (
|
|
585
|
+
cmd is None
|
|
586
|
+
and not create_intent
|
|
587
|
+
and _try_interactive_picker(
|
|
588
|
+
sandbox_id=sandbox_id, cwd=cwd, no_sanitize=no_sanitize, quiet=quiet
|
|
589
|
+
)
|
|
590
|
+
):
|
|
591
|
+
return
|
|
592
|
+
# Otherwise nothing was running and no id was given (or a create was intended):
|
|
593
|
+
# fall through to the create path below and open a fresh ephemeral sandbox.
|
|
594
|
+
|
|
595
|
+
egress_cfg, secret_objs = build_egress(
|
|
596
|
+
eai=eai, secret=secret, no_default_egress=no_default_egress
|
|
597
|
+
)
|
|
598
|
+
plan = _ShellPlan(
|
|
599
|
+
attaching=attaching,
|
|
600
|
+
sandbox_id=sandbox_id,
|
|
601
|
+
cmd=cmd,
|
|
602
|
+
cwd=cwd,
|
|
603
|
+
no_sanitize=no_sanitize,
|
|
604
|
+
quiet=quiet,
|
|
605
|
+
keep=keep,
|
|
606
|
+
image=image,
|
|
607
|
+
egress=egress_cfg,
|
|
608
|
+
secrets=secret_objs,
|
|
609
|
+
memory=memory,
|
|
610
|
+
cpu=cpu,
|
|
611
|
+
env_overrides=env_overrides,
|
|
612
|
+
)
|
|
613
|
+
try:
|
|
614
|
+
code = asyncio.run(_run_shell(plan))
|
|
615
|
+
except KeyboardInterrupt:
|
|
616
|
+
raise typer.Exit(130) from None
|
|
617
|
+
except SandboxError as exc:
|
|
618
|
+
typer.echo(f"error: {exc}", err=True)
|
|
619
|
+
raise typer.Exit(1) from exc
|
|
620
|
+
raise typer.Exit(code)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _emit_status(msg: str, *, quiet: bool) -> None:
|
|
624
|
+
"""Print a progress line to stderr, dimmed and marked with a leading ``»`` so
|
|
625
|
+
it reads as meta rather than the command's output. Click drops the styling
|
|
626
|
+
automatically when stderr is not a tty (piped), leaving plain ``» ...`` text.
|
|
627
|
+
``quiet`` mutes it entirely."""
|
|
628
|
+
if quiet:
|
|
629
|
+
return
|
|
630
|
+
typer.secho(f"» {msg}", err=True, dim=True)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
async def _exec_streaming(sb: Any, cmd: str, cwd: str | None, env: dict[str, str] | None) -> int:
|
|
634
|
+
"""Run `cmd` in `sb`, streaming output; return its exit code.
|
|
635
|
+
|
|
636
|
+
The command string is handed to `sh -c` so shell syntax (pipes, quoting,
|
|
637
|
+
`python -c '...'`) works exactly as typed.
|
|
638
|
+
"""
|
|
639
|
+
kwargs: dict[str, Any] = {}
|
|
640
|
+
if cwd:
|
|
641
|
+
kwargs["working_dir"] = cwd
|
|
642
|
+
if env:
|
|
643
|
+
kwargs["env"] = env
|
|
644
|
+
stream = sb.exec_stream(["sh", "-c", cmd], **kwargs)
|
|
645
|
+
async for line in stream:
|
|
646
|
+
typer.echo(line.data, err=(line.stream == "stderr"))
|
|
647
|
+
# ExecStream sets exit_code once the process is terminal; None means we never
|
|
648
|
+
# saw a terminal frame (cancelled/torn down), which is a failure, not a 0.
|
|
649
|
+
return stream.exit_code if stream.exit_code is not None else 1
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
async def _attach_pty(sb: Any, *, cwd: str | None, sanitize: bool, quiet: bool = False) -> int:
|
|
653
|
+
"""Open an interactive PTY in `sb` and return the shell's exit code."""
|
|
654
|
+
from snowflake.sandbox.__main__ import _terminal_size
|
|
655
|
+
|
|
656
|
+
rows, cols = _terminal_size()
|
|
657
|
+
sh = await sb.shell(rows=rows, cols=cols, cwd=cwd)
|
|
658
|
+
_emit_status(
|
|
659
|
+
f"Connected to {sb.name or sb.id} (session {sh.session_id}); exit the shell to disconnect",
|
|
660
|
+
quiet=quiet,
|
|
661
|
+
)
|
|
662
|
+
try:
|
|
663
|
+
code = await sh.attach(sanitize=sanitize)
|
|
664
|
+
finally:
|
|
665
|
+
await sh.close()
|
|
666
|
+
return 0 if code == 0 else (code if isinstance(code, int) and code > 0 else 1)
|