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,1091 @@
|
|
|
1
|
+
"""``snow sandbox run`` — the one verb for running a workload in a sandbox.
|
|
2
|
+
|
|
3
|
+
**TARGET is always a local path; images come only from ``--image``.** The shape of
|
|
4
|
+
a local TARGET is auto-detected, but image-vs-local never is — an unresolvable
|
|
5
|
+
TARGET is an error, not an image reference. Inferring it both ways would make the
|
|
6
|
+
dangerous direction silent: a bare image name that collides with a local
|
|
7
|
+
directory (``run myapp`` beside ``myapp/``) would upload that whole tree to
|
|
8
|
+
Snowflake instead of running the image, while the reverse mistake merely fails
|
|
9
|
+
loudly at pull. So:
|
|
10
|
+
|
|
11
|
+
* ``--image /repo/img:tag`` with **no TARGET** runs that image — with
|
|
12
|
+
``--command``, or with ``--detach`` to run the image's own entrypoint;
|
|
13
|
+
* ``--image`` *with* a local TARGET instead names the **base image** that the
|
|
14
|
+
uploaded code runs on;
|
|
15
|
+
* **one or more local paths** (files, directories, or a mix) are pushed into a
|
|
16
|
+
fresh sandbox and run there. A lone ``.py``/``.sh`` file gets its interpreter
|
|
17
|
+
from its extension; anything else needs ``--command``;
|
|
18
|
+
* ``--app MODULE.py`` deploys an App via the SDK's authoring path (what
|
|
19
|
+
``deploy`` used to do). It is **explicit**: a script is never treated as an App
|
|
20
|
+
because of what is inside it. The static AST check survives only as a *hint*,
|
|
21
|
+
and is gated on the file importing ``snowflake.sandbox`` — ``@app.function()``
|
|
22
|
+
is also Modal's API, so the decorator shape alone means nothing.
|
|
23
|
+
|
|
24
|
+
What is uploaded is never guessed silently. Credential-shaped files are excluded by
|
|
25
|
+
default (reusing the SDK's ``_is_credential_path``), ``$HOME`` and filesystem roots
|
|
26
|
+
are refused outright, ``--include``/``--exclude`` take gitignore-syntax globs
|
|
27
|
+
relative to each TARGET, and every run prints a manifest naming what was sent and
|
|
28
|
+
what was dropped and why. An upload that is *anomalous* rather than merely large --
|
|
29
|
+
over a fraction of the ``--memory`` tier, since ``/tmp`` is RAM-backed, or over a
|
|
30
|
+
file count that would be slow -- is refused until ``--allow-large``.
|
|
31
|
+
|
|
32
|
+
Lifecycle is a flag, not a verb. By default ``run`` is **run-to-completion**: it
|
|
33
|
+
streams output, exits with the command's code, and tears the sandbox down.
|
|
34
|
+
``--detach`` makes it **long-running** (detached; for an App this is the
|
|
35
|
+
deploy-detached path). Auto-restart across platform interruptions is a separate,
|
|
36
|
+
deferred feature (the create body has no restart policy yet).
|
|
37
|
+
|
|
38
|
+
Local delivery for the inferred targets is a direct push
|
|
39
|
+
(``PUT /containers/{id}/files``), not the stage bundle path — no stage, no zip,
|
|
40
|
+
capped per-file. An App target uses the SDK's ``Bundle`` path as before.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import ast
|
|
46
|
+
import asyncio
|
|
47
|
+
import shlex
|
|
48
|
+
import sys
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import TYPE_CHECKING, Any
|
|
52
|
+
|
|
53
|
+
import typer
|
|
54
|
+
|
|
55
|
+
from snowflake.cli_sandbox._common import (
|
|
56
|
+
_CONN_HELP,
|
|
57
|
+
_ENV_HELP,
|
|
58
|
+
_USE_SNOW_CONN_HELP,
|
|
59
|
+
_apply_connection,
|
|
60
|
+
_parse_env,
|
|
61
|
+
)
|
|
62
|
+
from snowflake.cli_sandbox._egress_flags import (
|
|
63
|
+
EAI_HELP,
|
|
64
|
+
NO_DEFAULT_EGRESS_HELP,
|
|
65
|
+
SECRET_HELP,
|
|
66
|
+
build_egress,
|
|
67
|
+
)
|
|
68
|
+
from snowflake.cli_sandbox._shell_command import (
|
|
69
|
+
_emit_status,
|
|
70
|
+
_exec_streaming,
|
|
71
|
+
_run_on_fresh_sandbox,
|
|
72
|
+
)
|
|
73
|
+
from snowflake.cli_sandbox._upload_plan import (
|
|
74
|
+
ResolvedTarget,
|
|
75
|
+
TargetError,
|
|
76
|
+
UploadPlan,
|
|
77
|
+
build_plan,
|
|
78
|
+
resolve_targets,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if TYPE_CHECKING:
|
|
82
|
+
from snowflake.sandbox._deploy_spec import DeploySpec
|
|
83
|
+
from snowflake.sandbox.client import AsyncSandbox
|
|
84
|
+
|
|
85
|
+
# 24h is proven safe on the fleet (SnowBots runs with it); a --detach box with
|
|
86
|
+
# no inbound traffic would otherwise be idle-reaped at CNG's shorter default.
|
|
87
|
+
_DETACH_IDLE_SUSPEND = "24h"
|
|
88
|
+
|
|
89
|
+
# Uploaded local code lands here; /tmp is world-writable on every base image. Note it
|
|
90
|
+
# is also RAM-backed, which is why the upload thresholds below scale with --memory.
|
|
91
|
+
_REMOTE_WORKDIR = "/tmp/sandbox-run"
|
|
92
|
+
|
|
93
|
+
# Matches the SDK's own per-file ceiling (`sandbox.files.MAX_FILE_BYTES`), checked here
|
|
94
|
+
# so an oversized file is refused before anything is created rather than aborting the
|
|
95
|
+
# upload half-way and leaving a partially-populated sandbox.
|
|
96
|
+
_MAX_FILE_BYTES = 25 * 1024 * 1024
|
|
97
|
+
|
|
98
|
+
# `--memory` default, mirrored from `_run_on_fresh_sandbox`; the byte gate is a
|
|
99
|
+
# fraction of it. Tuned to catch *anomalous*, not merely large: 20% of the default 4g
|
|
100
|
+
# is ~820 MB, which a real project reaches only by accident. Both are one edit to
|
|
101
|
+
# re-tune, and `--allow-large` overrides either.
|
|
102
|
+
_DEFAULT_MEMORY_TIER = "4g"
|
|
103
|
+
_UPLOAD_BYTE_FRACTION = 0.20
|
|
104
|
+
_UPLOAD_FILE_LIMIT = 2000
|
|
105
|
+
|
|
106
|
+
# A --detach local command is backgrounded (its process is not the container's
|
|
107
|
+
# managed process, unlike an image's command), so its output has no managed-process
|
|
108
|
+
# log capture — tee it to a file, which `logs --file` reads.
|
|
109
|
+
_DETACH_LOG = "/tmp/sandbox-run.out"
|
|
110
|
+
|
|
111
|
+
# Only runtimes present on the base image are auto-detected; anything else (a
|
|
112
|
+
# compiled toolchain, node when the base lacks it, ...) must pass --command.
|
|
113
|
+
_RUNNER_BY_SUFFIX = {".py": "python", ".sh": "bash", ".bash": "bash"}
|
|
114
|
+
|
|
115
|
+
# @app.<these> decorators mark an App entry (see snowflake.sandbox.app).
|
|
116
|
+
_APP_DECORATORS = frozenset({"function", "entrypoint", "session"})
|
|
117
|
+
|
|
118
|
+
_RUN_HELP = """Run a workload in a sandbox: local code (TARGET) or an image (--image).
|
|
119
|
+
|
|
120
|
+
TARGET is always a LOCAL PATH, repeatable -- never an image reference. Pass files,
|
|
121
|
+
directories, or a mix. A lone .py/.sh file gets its interpreter from its extension
|
|
122
|
+
(.py -> python, .sh -> bash); anything else needs --command.
|
|
123
|
+
|
|
124
|
+
To run an IMAGE, name it with --image. An image is never inferred from TARGET: a
|
|
125
|
+
bare image name can collide with a local directory, and guessing wrong would upload
|
|
126
|
+
that directory to Snowflake. With local TARGETs, --image is the base image they run
|
|
127
|
+
on. To deploy an App module, pass --app.
|
|
128
|
+
|
|
129
|
+
Uploads are filtered and reported. Credential-shaped files (.env, *.pem, id_rsa,
|
|
130
|
+
.ssh/, ...) are excluded by default; $HOME and filesystem roots are refused.
|
|
131
|
+
--exclude/--include take gitignore-syntax globs relative to each TARGET, so
|
|
132
|
+
`node_modules/` and `data/` work as written. Every run prints what it sent and what
|
|
133
|
+
it dropped.
|
|
134
|
+
|
|
135
|
+
By default this is run-to-completion: the output streams, the exit code is
|
|
136
|
+
returned, and the sandbox is torn down. --detach leaves it running (detached);
|
|
137
|
+
auto-restart across platform interruptions is a separate, deferred feature.
|
|
138
|
+
--dry-run prints the resolved plan without creating anything.
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
snow sandbox run agent.py # local script: stream, exit
|
|
142
|
+
snow sandbox run src/ config.yaml main.py # several paths at once
|
|
143
|
+
snow sandbox run ./project --command "python -m app"
|
|
144
|
+
snow sandbox run ./project --exclude "data/" --exclude "*.log"
|
|
145
|
+
snow sandbox run main.py --image /repo/node:20 --command "node main.js"
|
|
146
|
+
snow sandbox run agent.py --detach # leave it running
|
|
147
|
+
snow sandbox run --image /repo/snowbots:8 --detach --command "node /boot.mjs"
|
|
148
|
+
snow sandbox run --image /repo/snowbots:8 --detach # the image's own entrypoint
|
|
149
|
+
snow sandbox run --command "echo hi" # ad-hoc, default image
|
|
150
|
+
snow sandbox run --app agent_app.py --function triage
|
|
151
|
+
snow sandbox run --app agent_app.py --dry-run
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _imports_the_sdk(tree: ast.AST) -> bool:
|
|
156
|
+
"""True when the module imports `snowflake.sandbox` in any spelling.
|
|
157
|
+
|
|
158
|
+
Gates the App hint. `@app.function()` and `App(...)` are *also* Modal's API
|
|
159
|
+
(`app = modal.App(...)`), and Textual exports an `App` too — so the decorator
|
|
160
|
+
shape alone false-positives on entirely unrelated code. Requiring an SDK import
|
|
161
|
+
keeps the hint from firing on a Modal script.
|
|
162
|
+
"""
|
|
163
|
+
for node in ast.walk(tree):
|
|
164
|
+
if isinstance(node, ast.Import) and any(
|
|
165
|
+
a.name.startswith("snowflake.sandbox") for a in node.names
|
|
166
|
+
):
|
|
167
|
+
return True
|
|
168
|
+
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("snowflake.sandbox"):
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _looks_like_app(path: Path) -> bool:
|
|
174
|
+
"""True if *path* statically looks like an SDK App module.
|
|
175
|
+
|
|
176
|
+
Requires **both** an SDK import and an ``App(...)`` call or an
|
|
177
|
+
``@app.function``/``@app.entrypoint``/``@app.session`` decorator. Parses the AST
|
|
178
|
+
only -- it never imports or runs the file.
|
|
179
|
+
|
|
180
|
+
This no longer decides anything: `--app` selects the App path explicitly, and this
|
|
181
|
+
only powers a hint. That matters because the decorator shape is not ours alone --
|
|
182
|
+
Modal is `app = modal.App(...)` with `@app.function()` -- so without the import
|
|
183
|
+
gate the hint would fire on every Modal script in the world.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
|
|
187
|
+
except (OSError, SyntaxError, ValueError):
|
|
188
|
+
return False
|
|
189
|
+
if not _imports_the_sdk(tree):
|
|
190
|
+
return False
|
|
191
|
+
for node in ast.walk(tree):
|
|
192
|
+
if isinstance(node, ast.Call):
|
|
193
|
+
fn = node.func
|
|
194
|
+
if (isinstance(fn, ast.Name) and fn.id == "App") or (
|
|
195
|
+
isinstance(fn, ast.Attribute) and fn.attr == "App"
|
|
196
|
+
):
|
|
197
|
+
return True
|
|
198
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
199
|
+
for dec in node.decorator_list:
|
|
200
|
+
target = dec.func if isinstance(dec, ast.Call) else dec
|
|
201
|
+
if isinstance(target, ast.Attribute) and target.attr in _APP_DECORATORS:
|
|
202
|
+
return True
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _detect_file_command(path: Path) -> str | None:
|
|
207
|
+
"""A shell command to run a single local file by extension, or None if unknown."""
|
|
208
|
+
runner = _RUNNER_BY_SUFFIX.get(path.suffix)
|
|
209
|
+
return f"{runner} {shlex.quote(path.name)}" if runner else None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _load_app_spec(
|
|
213
|
+
module_path: str | None, function: str | None, env_overrides: dict[str, str]
|
|
214
|
+
) -> DeploySpec:
|
|
215
|
+
"""Import a Python module by path and pull a DeploySpec out of its App.
|
|
216
|
+
|
|
217
|
+
The module defines the App, so importing it is how we discover what to run.
|
|
218
|
+
Reached only after `_looks_like_app` matched, so this is not run on an arbitrary
|
|
219
|
+
script.
|
|
220
|
+
"""
|
|
221
|
+
import importlib.util
|
|
222
|
+
|
|
223
|
+
if module_path is None:
|
|
224
|
+
raise typer.BadParameter("provide MODULE (e.g. agent.py)")
|
|
225
|
+
src = Path(module_path).expanduser().resolve()
|
|
226
|
+
if src.is_dir():
|
|
227
|
+
raise typer.BadParameter(
|
|
228
|
+
f"{module_path} is a directory. sandbox.toml is retired — point at the "
|
|
229
|
+
f"Python module that defines your App, e.g. `snow sandbox run --app agent.py`."
|
|
230
|
+
)
|
|
231
|
+
if not src.is_file():
|
|
232
|
+
raise typer.BadParameter(f"module not found: {src}")
|
|
233
|
+
|
|
234
|
+
sys.path.insert(0, str(src.parent))
|
|
235
|
+
spec_obj = importlib.util.spec_from_file_location(src.stem, src)
|
|
236
|
+
if spec_obj is None or spec_obj.loader is None:
|
|
237
|
+
raise typer.BadParameter(f"cannot import {src}")
|
|
238
|
+
module = importlib.util.module_from_spec(spec_obj)
|
|
239
|
+
try:
|
|
240
|
+
spec_obj.loader.exec_module(module)
|
|
241
|
+
except Exception as exc: # noqa: BLE001 - surface the user's import error
|
|
242
|
+
raise typer.BadParameter(f"failed to import {src}: {exc}") from exc
|
|
243
|
+
|
|
244
|
+
from snowflake.sandbox.app import App as _App
|
|
245
|
+
|
|
246
|
+
apps = [v for v in vars(module).values() if isinstance(v, _App)]
|
|
247
|
+
if not apps:
|
|
248
|
+
raise typer.BadParameter(
|
|
249
|
+
f'no App found in {src.name}. Define one: app = App("my-agent") '
|
|
250
|
+
f"with an @app.function(...)."
|
|
251
|
+
)
|
|
252
|
+
if len(apps) > 1:
|
|
253
|
+
raise typer.BadParameter(
|
|
254
|
+
f"{src.name} defines {len(apps)} Apps; a module should define one."
|
|
255
|
+
)
|
|
256
|
+
try:
|
|
257
|
+
spec = apps[0].resolve(function).deploy_spec(**env_overrides)
|
|
258
|
+
except ValueError as exc:
|
|
259
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
260
|
+
|
|
261
|
+
# A relative bundle root means "next to the module", not the invoking cwd.
|
|
262
|
+
if spec.bundle is not None and not Path(spec.bundle.root).is_absolute():
|
|
263
|
+
from dataclasses import replace
|
|
264
|
+
|
|
265
|
+
from snowflake.sandbox._assemble import Bundle
|
|
266
|
+
|
|
267
|
+
rooted = (src.parent / spec.bundle.root).resolve()
|
|
268
|
+
spec = replace(
|
|
269
|
+
spec,
|
|
270
|
+
bundle=Bundle(
|
|
271
|
+
root=str(rooted), include=spec.bundle.include, exclude=spec.bundle.exclude
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
return spec
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _print_preflight_findings(result: Any) -> None:
|
|
278
|
+
"""Echo the preflight problems; exit(2) if any are errors (nothing ran)."""
|
|
279
|
+
errors = [p for p in result.problems if p.severity == "error"]
|
|
280
|
+
for p in result.problems:
|
|
281
|
+
fix_note = f" -> fix: {p.fix}" if p.fix else ""
|
|
282
|
+
typer.echo(f"[{p.severity}] {p.message}{fix_note}", err=(p.severity == "error"))
|
|
283
|
+
if errors:
|
|
284
|
+
typer.echo("preflight failed; nothing ran.", err=True)
|
|
285
|
+
raise typer.Exit(2)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _print_app_plan(result: Any) -> None:
|
|
289
|
+
"""Print the resolved App deploy plan (``--dry-run``) and exit 0."""
|
|
290
|
+
pl = result.plan
|
|
291
|
+
typer.echo("target: App module")
|
|
292
|
+
typer.echo(f"image: {pl.image}")
|
|
293
|
+
typer.echo(f"memory: {pl.memory}")
|
|
294
|
+
if pl.cpu is not None:
|
|
295
|
+
typer.echo(f"cpu: {pl.cpu}")
|
|
296
|
+
typer.echo(f"entry: {' '.join(pl.entry)}")
|
|
297
|
+
typer.echo(f"code_stage: {pl.code_stage or '(none)'}")
|
|
298
|
+
typer.echo(f"egress: {pl.egress}")
|
|
299
|
+
typer.echo(f"env keys: {', '.join(pl.env_keys) or '(none)'}")
|
|
300
|
+
typer.echo(f"bundle ({len(pl.bundle_files)} files):")
|
|
301
|
+
for f in pl.bundle_files:
|
|
302
|
+
typer.echo(f" {f}")
|
|
303
|
+
raise typer.Exit(0)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _print_inferred_plan(plan: _RunPlan) -> None:
|
|
307
|
+
"""Print the resolved plan for an image/file/dir target (``--dry-run``), exit 0.
|
|
308
|
+
|
|
309
|
+
Needs no live connection — nothing is created, so this is a client-side echo."""
|
|
310
|
+
typer.echo(f"target: {'image (no TARGET)' if plan.kind == 'image' else plan.kind}")
|
|
311
|
+
typer.echo(f"image: {plan.base_image or '(deployment default)'}")
|
|
312
|
+
typer.echo(f"command: {plan.command or '(image default)'}")
|
|
313
|
+
if plan.egress is not None:
|
|
314
|
+
eais = getattr(plan.egress, "external_access_integrations", ())
|
|
315
|
+
closed = getattr(plan.egress, "allow_default_egress", None) is False
|
|
316
|
+
bits = [f"eai={','.join(eais)}" if eais else "", "default-egress=closed" if closed else ""]
|
|
317
|
+
if plan.secrets:
|
|
318
|
+
bits.append(f"secrets={len(plan.secrets)}")
|
|
319
|
+
typer.echo(f"egress: {' '.join(b for b in bits if b)}")
|
|
320
|
+
if plan.upload is not None:
|
|
321
|
+
typer.echo(
|
|
322
|
+
f"upload: {plan.upload.file_count} file(s), "
|
|
323
|
+
f"{_human(plan.upload.total_bytes)} -> {_REMOTE_WORKDIR}"
|
|
324
|
+
)
|
|
325
|
+
for item in plan.upload.selected:
|
|
326
|
+
typer.echo(f" {item.rel}")
|
|
327
|
+
if why := _describe_exclusions(plan.upload):
|
|
328
|
+
typer.echo(f"excluded: {why}")
|
|
329
|
+
typer.echo(f"cwd: {plan.upload.cwd}")
|
|
330
|
+
typer.echo(f"detach: {plan.detach}")
|
|
331
|
+
if plan.idle_suspend:
|
|
332
|
+
typer.echo(f"idle_suspend: {plan.idle_suspend}")
|
|
333
|
+
typer.echo(f"env keys: {', '.join(plan.env) or '(none)'}")
|
|
334
|
+
raise typer.Exit(0)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass(frozen=True)
|
|
338
|
+
class _RunPlan:
|
|
339
|
+
"""The resolved ``run`` invocation, threaded to the async workers so they stay
|
|
340
|
+
module-level and independently testable rather than closures over the flags."""
|
|
341
|
+
|
|
342
|
+
kind: str # 'image' (no TARGET) | 'local' (paths) | 'app' (--app)
|
|
343
|
+
app_path: Path | None # --app module
|
|
344
|
+
upload: UploadPlan | None # the settled selection; None unless kind == 'local'
|
|
345
|
+
command: str | None # shell command; None for an App or a detached image (own entry)
|
|
346
|
+
base_image: str # '' = the deployment's default runtime image (unused for 'app')
|
|
347
|
+
detach: bool
|
|
348
|
+
idle_suspend: str | None
|
|
349
|
+
memory: str | None
|
|
350
|
+
cpu: float | None
|
|
351
|
+
name: str | None
|
|
352
|
+
function: str | None # App-module target only
|
|
353
|
+
env: dict[str, str]
|
|
354
|
+
egress: object | None # Egress | None -- typed loosely to keep the SDK import lazy
|
|
355
|
+
secrets: tuple[object, ...]
|
|
356
|
+
quiet: bool
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _reject_app_incompatible_flags(
|
|
360
|
+
*,
|
|
361
|
+
n_targets: int,
|
|
362
|
+
command: str | None,
|
|
363
|
+
name: str | None,
|
|
364
|
+
idle_suspend: str | None,
|
|
365
|
+
image: str | None,
|
|
366
|
+
selection_flags: dict[str, object],
|
|
367
|
+
eai: list[str] | None,
|
|
368
|
+
secret: list[str] | None,
|
|
369
|
+
nde: bool,
|
|
370
|
+
) -> None:
|
|
371
|
+
"""Everything an App module declares for itself, and so cannot be told from a flag.
|
|
372
|
+
|
|
373
|
+
Split from `_reject_inapplicable_flags` purely to keep each half under the
|
|
374
|
+
complexity limit; the App rules are a self-contained set.
|
|
375
|
+
"""
|
|
376
|
+
if n_targets:
|
|
377
|
+
raise typer.BadParameter(
|
|
378
|
+
"--app takes the module itself; do not also pass a TARGET "
|
|
379
|
+
"(`run --app agent_app.py`, not `run --app agent_app.py extra.py`)."
|
|
380
|
+
)
|
|
381
|
+
if command is not None:
|
|
382
|
+
raise typer.BadParameter(
|
|
383
|
+
"--command does not apply to an App module; the App defines its own entry."
|
|
384
|
+
)
|
|
385
|
+
if name is not None:
|
|
386
|
+
raise typer.BadParameter("--name does not apply to an App module (assigned on deploy).")
|
|
387
|
+
if idle_suspend is not None:
|
|
388
|
+
raise typer.BadParameter("--idle-suspend applies only to an image or local-code run.")
|
|
389
|
+
if image is not None:
|
|
390
|
+
raise typer.BadParameter("--image does not apply to an App module (the App declares it).")
|
|
391
|
+
for flag, value in selection_flags.items():
|
|
392
|
+
if value:
|
|
393
|
+
raise typer.BadParameter(
|
|
394
|
+
f"{flag} applies only to local code; an App declares its own bundle."
|
|
395
|
+
)
|
|
396
|
+
for flag, value in (("--eai", eai), ("--secret", secret), ("--no-default-egress", nde)):
|
|
397
|
+
if value:
|
|
398
|
+
raise typer.BadParameter(
|
|
399
|
+
f"{flag} does not apply to an App module; an App declares its own egress "
|
|
400
|
+
f"and secrets in code (see @app.function(egress=..., secrets=...))."
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _reject_inapplicable_flags(
|
|
405
|
+
*,
|
|
406
|
+
kind: str,
|
|
407
|
+
detach: bool,
|
|
408
|
+
function: str | None,
|
|
409
|
+
name: str | None,
|
|
410
|
+
idle_suspend: str | None,
|
|
411
|
+
command: str | None,
|
|
412
|
+
image: str | None,
|
|
413
|
+
n_targets: int,
|
|
414
|
+
selection_flags: dict[str, object],
|
|
415
|
+
eai: list[str] | None = None,
|
|
416
|
+
secret: list[str] | None = None,
|
|
417
|
+
nde: bool = False,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Reject flag combinations that cannot take effect, loudly rather than silently.
|
|
420
|
+
|
|
421
|
+
Everything here is a *shape* error the user can fix by retyping, so each message
|
|
422
|
+
names the flag at fault and, where it is not obvious, why it cannot apply.
|
|
423
|
+
"""
|
|
424
|
+
if kind == "app":
|
|
425
|
+
_reject_app_incompatible_flags(
|
|
426
|
+
n_targets=n_targets,
|
|
427
|
+
command=command,
|
|
428
|
+
name=name,
|
|
429
|
+
idle_suspend=idle_suspend,
|
|
430
|
+
image=image,
|
|
431
|
+
selection_flags=selection_flags,
|
|
432
|
+
eai=eai,
|
|
433
|
+
secret=secret,
|
|
434
|
+
nde=nde,
|
|
435
|
+
)
|
|
436
|
+
elif function is not None:
|
|
437
|
+
raise typer.BadParameter("--function applies only to an App module (--app).")
|
|
438
|
+
|
|
439
|
+
if not n_targets:
|
|
440
|
+
for flag, value in selection_flags.items():
|
|
441
|
+
if value:
|
|
442
|
+
raise typer.BadParameter(f"{flag} applies only when uploading local code.")
|
|
443
|
+
|
|
444
|
+
if name is not None and not detach:
|
|
445
|
+
raise typer.BadParameter(
|
|
446
|
+
"--name only applies with --detach (a run-to-completion job is ephemeral)."
|
|
447
|
+
)
|
|
448
|
+
if idle_suspend is not None and not detach:
|
|
449
|
+
raise typer.BadParameter("--idle-suspend applies only to a --detach run.")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _memory_bytes(tier: str | None) -> int:
|
|
453
|
+
"""The sandbox's RAM in bytes, from the `--memory` tier (default 4g)."""
|
|
454
|
+
text = (tier or _DEFAULT_MEMORY_TIER).strip().lower().rstrip("b")
|
|
455
|
+
try:
|
|
456
|
+
return int(float(text.rstrip("g")) * 1024**3) if text.endswith("g") else int(text)
|
|
457
|
+
except ValueError: # an unparseable tier is the server's error to give, not ours
|
|
458
|
+
return int(float(_DEFAULT_MEMORY_TIER.rstrip("g")) * 1024**3)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _reject_oversized_upload(plan: UploadPlan, *, memory: str | None, allow_large: bool) -> None:
|
|
462
|
+
"""Refuse an upload that is anomalous rather than merely large.
|
|
463
|
+
|
|
464
|
+
Two gates, whichever trips first. The byte gate is a fraction of the memory tier
|
|
465
|
+
because `_REMOTE_WORKDIR` is under `/tmp`, which is **RAM-backed** -- uploaded
|
|
466
|
+
bytes are taken out of the same budget the workload runs in, so "too big" is
|
|
467
|
+
relative to `--memory`, not absolute. The count gate is separate because a
|
|
468
|
+
few-thousand-file tree is a wall-clock problem, not a memory one: uploads are one
|
|
469
|
+
sequential request per file.
|
|
470
|
+
|
|
471
|
+
Refuses everywhere, with no interactive branch: a pipeline doing something this
|
|
472
|
+
unusual should stop, not prompt.
|
|
473
|
+
"""
|
|
474
|
+
if allow_large:
|
|
475
|
+
return
|
|
476
|
+
budget = int(_UPLOAD_BYTE_FRACTION * _memory_bytes(memory))
|
|
477
|
+
if plan.total_bytes > budget:
|
|
478
|
+
raise typer.BadParameter(
|
|
479
|
+
f"would upload {_human(plan.total_bytes)} into /tmp on a "
|
|
480
|
+
f"{memory or _DEFAULT_MEMORY_TIER} sandbox, over the "
|
|
481
|
+
f"{_human(budget)} limit; /tmp is RAM-backed, so this competes with the "
|
|
482
|
+
f"workload. Narrow it with --include/--exclude, raise --memory, or pass "
|
|
483
|
+
f"--allow-large."
|
|
484
|
+
)
|
|
485
|
+
if plan.file_count > _UPLOAD_FILE_LIMIT:
|
|
486
|
+
raise typer.BadParameter(
|
|
487
|
+
f"would upload {plan.file_count} files, over the {_UPLOAD_FILE_LIMIT} "
|
|
488
|
+
f"limit (each is a separate request, so this would be slow). Narrow it "
|
|
489
|
+
f"with --include/--exclude, or pass --allow-large."
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _human(size: int) -> str:
|
|
494
|
+
"""Bytes as a short human string; the manifest and the refusals share it."""
|
|
495
|
+
value = float(size)
|
|
496
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
497
|
+
if value < 1024 or unit == "GB":
|
|
498
|
+
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
|
499
|
+
value /= 1024
|
|
500
|
+
return f"{value:.1f} GB"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _plan_from_args(
|
|
504
|
+
*,
|
|
505
|
+
targets: list[str] | None,
|
|
506
|
+
app: str | None,
|
|
507
|
+
command: str | None,
|
|
508
|
+
detach: bool,
|
|
509
|
+
envs: list[str] | None,
|
|
510
|
+
image: str | None,
|
|
511
|
+
memory: str | None,
|
|
512
|
+
cpu: float | None,
|
|
513
|
+
idle_suspend: str | None,
|
|
514
|
+
name: str | None,
|
|
515
|
+
function: str | None,
|
|
516
|
+
include: list[str] | None = None,
|
|
517
|
+
exclude: list[str] | None = None,
|
|
518
|
+
allow_credential_file: list[str] | None = None,
|
|
519
|
+
allow_large: bool = False,
|
|
520
|
+
eai: list[str] | None = None,
|
|
521
|
+
secret: list[str] | None = None,
|
|
522
|
+
no_default_egress: bool = False,
|
|
523
|
+
quiet: bool = False,
|
|
524
|
+
) -> _RunPlan:
|
|
525
|
+
"""Validate the flags and settle a `_RunPlan` (raising `typer.BadParameter`).
|
|
526
|
+
|
|
527
|
+
Three shapes, decided entirely by what the user typed -- never by inspecting the
|
|
528
|
+
filesystem or the contents of a file: `--app` is an App module, TARGETs are local
|
|
529
|
+
paths, and neither means the image (or the default image) is the workload.
|
|
530
|
+
"""
|
|
531
|
+
given = list(targets or [])
|
|
532
|
+
kind = "app" if app else ("local" if given else "image")
|
|
533
|
+
|
|
534
|
+
selection_flags: dict[str, object] = {
|
|
535
|
+
"--include": include,
|
|
536
|
+
"--exclude": exclude,
|
|
537
|
+
"--allow-credential-file": allow_credential_file,
|
|
538
|
+
"--allow-large": allow_large,
|
|
539
|
+
}
|
|
540
|
+
_reject_inapplicable_flags(
|
|
541
|
+
kind=kind,
|
|
542
|
+
detach=detach,
|
|
543
|
+
function=function,
|
|
544
|
+
name=name,
|
|
545
|
+
idle_suspend=idle_suspend,
|
|
546
|
+
command=command,
|
|
547
|
+
image=image,
|
|
548
|
+
n_targets=len(given),
|
|
549
|
+
selection_flags=selection_flags,
|
|
550
|
+
eai=eai,
|
|
551
|
+
secret=secret,
|
|
552
|
+
nde=no_default_egress,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
env_overrides: dict[str, str] = {}
|
|
556
|
+
for raw in envs or []:
|
|
557
|
+
k, v = _parse_env(raw)
|
|
558
|
+
env_overrides[k] = v
|
|
559
|
+
|
|
560
|
+
# An App declares its own egress and secrets in code, so the flags would be
|
|
561
|
+
# silently overridden rather than merged -- rejected above in
|
|
562
|
+
# `_reject_inapplicable_flags`, which is why this can run unconditionally.
|
|
563
|
+
egress_cfg, secret_objs = build_egress(
|
|
564
|
+
eai=eai, secret=secret, no_default_egress=no_default_egress
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
upload: UploadPlan | None = None
|
|
568
|
+
resolved = command
|
|
569
|
+
app_path: Path | None = None
|
|
570
|
+
|
|
571
|
+
if kind == "app":
|
|
572
|
+
app_path = Path(app).expanduser() # type: ignore[arg-type] # kind=='app' => app set
|
|
573
|
+
resolved = None # the App declares its own entry
|
|
574
|
+
elif kind == "local":
|
|
575
|
+
try:
|
|
576
|
+
resolved_targets = resolve_targets(given)
|
|
577
|
+
upload = build_plan(
|
|
578
|
+
resolved_targets,
|
|
579
|
+
dest_root=_REMOTE_WORKDIR,
|
|
580
|
+
include=include,
|
|
581
|
+
exclude=exclude,
|
|
582
|
+
allow_credential_files=allow_credential_file,
|
|
583
|
+
max_file_bytes=_MAX_FILE_BYTES,
|
|
584
|
+
)
|
|
585
|
+
except TargetError as exc:
|
|
586
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
587
|
+
if not upload.selected:
|
|
588
|
+
# Name the flag that actually applies: --include cannot resurrect a
|
|
589
|
+
# credential-shaped file by design, so pointing at it here would send the
|
|
590
|
+
# user in a circle.
|
|
591
|
+
reasons = upload.skipped_by_reason()
|
|
592
|
+
if reasons.get("credential") and len(reasons) == 1:
|
|
593
|
+
fix = "--allow-credential-file PATH names one of them explicitly"
|
|
594
|
+
elif reasons.get("rule"):
|
|
595
|
+
fix = "widen it with --include, or relax --exclude"
|
|
596
|
+
else:
|
|
597
|
+
fix = "point at different paths"
|
|
598
|
+
raise typer.BadParameter(
|
|
599
|
+
f"nothing to upload from {', '.join(repr(t) for t in given)} "
|
|
600
|
+
f"({_describe_exclusions(upload) or 'the paths hold no regular files'}): "
|
|
601
|
+
f"{fix}."
|
|
602
|
+
)
|
|
603
|
+
if upload.oversized:
|
|
604
|
+
worst = max(upload.oversized, key=lambda s: s.size)
|
|
605
|
+
raise typer.BadParameter(
|
|
606
|
+
f"{worst.rel} is {_human(worst.size)}, over the "
|
|
607
|
+
f"{_human(_MAX_FILE_BYTES)} per-file limit. Exclude it and mount a "
|
|
608
|
+
f"stage for large files."
|
|
609
|
+
)
|
|
610
|
+
_reject_oversized_upload(upload, memory=memory, allow_large=allow_large)
|
|
611
|
+
if resolved is None:
|
|
612
|
+
resolved = _detect_single_file_command(resolved_targets)
|
|
613
|
+
if resolved is None:
|
|
614
|
+
raise typer.BadParameter(
|
|
615
|
+
f"could not determine how to run {', '.join(repr(t) for t in given)}: "
|
|
616
|
+
f"pass --command. (A command is inferred only for a lone .py/.sh/.bash "
|
|
617
|
+
f"file.)"
|
|
618
|
+
)
|
|
619
|
+
else:
|
|
620
|
+
# No TARGET and no --app: the image is the workload. Without --detach there is
|
|
621
|
+
# nothing to stream to completion (`_run_job` execs a command), so a command
|
|
622
|
+
# is required; with --detach the image's own entrypoint becomes the process.
|
|
623
|
+
if resolved is None and not detach:
|
|
624
|
+
raise typer.BadParameter(
|
|
625
|
+
"nothing to run: pass a TARGET (local path), --command, --app, or "
|
|
626
|
+
"--image with --detach to run the image's own entrypoint."
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
return _RunPlan(
|
|
630
|
+
kind=kind,
|
|
631
|
+
app_path=app_path,
|
|
632
|
+
upload=upload,
|
|
633
|
+
command=resolved,
|
|
634
|
+
base_image="" if kind == "app" else (image or ""),
|
|
635
|
+
detach=detach,
|
|
636
|
+
idle_suspend=(idle_suspend or _DETACH_IDLE_SUSPEND) if (detach and kind != "app") else None,
|
|
637
|
+
memory=memory,
|
|
638
|
+
cpu=cpu,
|
|
639
|
+
name=name,
|
|
640
|
+
function=function,
|
|
641
|
+
env=env_overrides,
|
|
642
|
+
egress=egress_cfg,
|
|
643
|
+
secrets=secret_objs,
|
|
644
|
+
quiet=quiet,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _detect_single_file_command(targets: tuple[ResolvedTarget, ...]) -> str | None:
|
|
649
|
+
"""The command for a lone file target, by extension, or None.
|
|
650
|
+
|
|
651
|
+
Only a *single* file target gets one: with several targets there is no basis for
|
|
652
|
+
picking which to run. Detection deliberately still applies when `--image` was
|
|
653
|
+
given -- `.py` means `python` wherever it runs -- and if that image has no
|
|
654
|
+
interpreter the run fails loudly (see `_hint_missing_interpreter`).
|
|
655
|
+
"""
|
|
656
|
+
if len(targets) != 1 or targets[0].is_dir:
|
|
657
|
+
return None
|
|
658
|
+
return _detect_file_command(Path(targets[0].basename))
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _hint_app_module(plan: _RunPlan) -> None:
|
|
662
|
+
"""Mention `--app` when a script-run target is statically an App module.
|
|
663
|
+
|
|
664
|
+
This is the whole remaining role of App detection: it *informs*, it never decides.
|
|
665
|
+
`run agent_app.py` runs the module as a script, which for an App file usually does
|
|
666
|
+
nothing visible -- so a nudge is worth printing, while silently switching to a
|
|
667
|
+
different execution path (what the old behaviour did) is not.
|
|
668
|
+
"""
|
|
669
|
+
if plan.kind != "local" or plan.upload is None:
|
|
670
|
+
return
|
|
671
|
+
for item in plan.upload.selected:
|
|
672
|
+
if item.source.suffix == ".py" and _looks_like_app(item.source):
|
|
673
|
+
typer.echo(
|
|
674
|
+
f"note: {item.rel} declares an App; to deploy it use "
|
|
675
|
+
f"`--app {item.source.name}` (running it as a script may do nothing).",
|
|
676
|
+
err=True,
|
|
677
|
+
)
|
|
678
|
+
return
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _describe_exclusions(plan: UploadPlan) -> str:
|
|
682
|
+
""" "3 credential-shaped, 2 vcs/build" -- the manifest's why-clause."""
|
|
683
|
+
labels = {
|
|
684
|
+
"credential": "credential-shaped",
|
|
685
|
+
"default": "vcs/build",
|
|
686
|
+
"rule": "excluded by a rule",
|
|
687
|
+
"symlink-file": "symlinked file",
|
|
688
|
+
"symlink-dir": "symlinked directory",
|
|
689
|
+
"not-regular": "not a regular file",
|
|
690
|
+
"unreadable": "unreadable",
|
|
691
|
+
"bad-name": "unusable name",
|
|
692
|
+
}
|
|
693
|
+
counts = plan.skipped_by_reason()
|
|
694
|
+
return ", ".join(f"{n} {labels.get(r, r)}" for r, n in sorted(counts.items()))
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _print_manifest(plan: UploadPlan, *, quiet: bool) -> None:
|
|
698
|
+
"""Say what is about to leave the machine, before it does.
|
|
699
|
+
|
|
700
|
+
Advisory, so it goes to stderr and `--quiet` silences it -- but refusals and
|
|
701
|
+
errors never are, so those still print. Top-level entries only: listing every
|
|
702
|
+
file would bury the counts that matter.
|
|
703
|
+
"""
|
|
704
|
+
if quiet:
|
|
705
|
+
return
|
|
706
|
+
tops: dict[str, int] = {}
|
|
707
|
+
for item in plan.selected:
|
|
708
|
+
tops[item.rel.split("/")[0]] = tops.get(item.rel.split("/")[0], 0) + 1
|
|
709
|
+
shown = ", ".join(
|
|
710
|
+
f"{name} ({count})" if count > 1 else name for name, count in sorted(tops.items())
|
|
711
|
+
)
|
|
712
|
+
_emit_status(
|
|
713
|
+
f"upload: {plan.file_count} file(s), {_human(plan.total_bytes)} -> {_REMOTE_WORKDIR}",
|
|
714
|
+
quiet=quiet,
|
|
715
|
+
)
|
|
716
|
+
_emit_status(f" {shown}", quiet=quiet)
|
|
717
|
+
if why := _describe_exclusions(plan):
|
|
718
|
+
_emit_status(f" excluded: {why}", quiet=quiet)
|
|
719
|
+
# A symlinked directory contributes *no* files and is the one exclusion that
|
|
720
|
+
# silently loses a whole subtree, so it is named rather than only counted.
|
|
721
|
+
for dropped in plan.skipped:
|
|
722
|
+
if dropped.reason == "symlink-dir":
|
|
723
|
+
_emit_status(
|
|
724
|
+
f" note: {dropped.rel} is a symlink; its contents were not sent", quiet=quiet
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
async def _upload_target(sb: AsyncSandbox, plan: _RunPlan) -> str:
|
|
729
|
+
"""Push the selected files into the box; return the cwd for the command.
|
|
730
|
+
|
|
731
|
+
No `make_directory` calls: `upload_file` creates parent directories itself, and
|
|
732
|
+
each `make_directory` is a `python3 -c` exec round trip rather than a file
|
|
733
|
+
operation -- so dropping them removes one request per directory.
|
|
734
|
+
"""
|
|
735
|
+
upload = plan.upload
|
|
736
|
+
assert upload is not None
|
|
737
|
+
total = upload.file_count
|
|
738
|
+
# Progress every 10%, so a few-thousand-file tree does not look hung behind a
|
|
739
|
+
# single status line. Never for a handful of files, where it would be noise.
|
|
740
|
+
step = max(1, total // 10) if total > 25 else total + 1
|
|
741
|
+
for index, item in enumerate(upload.selected, start=1):
|
|
742
|
+
await sb.upload_file(str(item.source), f"{_REMOTE_WORKDIR}/{item.rel}")
|
|
743
|
+
if index % step == 0 and index != total:
|
|
744
|
+
_emit_status(f"uploading... {index}/{total}", quiet=plan.quiet)
|
|
745
|
+
return upload.cwd
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _hint_missing_interpreter(plan: _RunPlan, code: int) -> None:
|
|
749
|
+
"""After a 127, say the likely cause: the image has no such interpreter.
|
|
750
|
+
|
|
751
|
+
Extension detection applies even when `--image` names a custom image, so
|
|
752
|
+
`run main.py --image /repo/scratch:1` resolves to `python main.py` on an image
|
|
753
|
+
that may have no python. The exit code alone (`sh: python: not found`) does not
|
|
754
|
+
mention the flag that fixes it.
|
|
755
|
+
"""
|
|
756
|
+
if code != 127 or plan.command is None:
|
|
757
|
+
return
|
|
758
|
+
runner = plan.command.split()[0]
|
|
759
|
+
where = plan.base_image or "the default image"
|
|
760
|
+
typer.echo(
|
|
761
|
+
f"hint: exit 127 usually means {runner!r} is not on {where}. "
|
|
762
|
+
f"Pass --command to run it another way, or use an image that has it.",
|
|
763
|
+
err=True,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
async def _run_job(plan: _RunPlan) -> int:
|
|
768
|
+
"""Run-to-completion: create a fresh box, upload local code (if any), stream the
|
|
769
|
+
command, and tear the box down — via the shared `_run_on_fresh_sandbox` path."""
|
|
770
|
+
|
|
771
|
+
async def _use(sb: AsyncSandbox) -> int:
|
|
772
|
+
cwd: str | None = None
|
|
773
|
+
if plan.upload is not None:
|
|
774
|
+
_print_manifest(plan.upload, quiet=plan.quiet)
|
|
775
|
+
cwd = await _upload_target(sb, plan)
|
|
776
|
+
assert plan.command is not None # a job always has a command (settled in _plan_from_args)
|
|
777
|
+
return await _exec_streaming(sb, plan.command, cwd, None)
|
|
778
|
+
|
|
779
|
+
return await _run_on_fresh_sandbox(
|
|
780
|
+
use=_use,
|
|
781
|
+
keep=False,
|
|
782
|
+
quiet=plan.quiet,
|
|
783
|
+
announce_ready=True,
|
|
784
|
+
image=plan.base_image,
|
|
785
|
+
memory=plan.memory,
|
|
786
|
+
cpu=plan.cpu,
|
|
787
|
+
env=plan.env or None,
|
|
788
|
+
egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
|
|
789
|
+
secrets=plan.secrets, # type: ignore[arg-type]
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
async def _run_detached_image(plan: _RunPlan) -> None:
|
|
794
|
+
"""Long-running image: create detached with the command as the managed process
|
|
795
|
+
(or the image's own entrypoint when no command), print the handle."""
|
|
796
|
+
command = ["sh", "-c", plan.command] if plan.command else None
|
|
797
|
+
|
|
798
|
+
async def _use(sb: AsyncSandbox) -> int:
|
|
799
|
+
typer.echo(f"running (detached): {sb.name or sb.id}")
|
|
800
|
+
typer.echo(f"logs: snow sandbox logs {sb.name or sb.id}", err=True)
|
|
801
|
+
return 0
|
|
802
|
+
|
|
803
|
+
await _run_on_fresh_sandbox(
|
|
804
|
+
use=_use,
|
|
805
|
+
keep=True,
|
|
806
|
+
quiet=plan.quiet,
|
|
807
|
+
image=plan.base_image,
|
|
808
|
+
memory=plan.memory,
|
|
809
|
+
cpu=plan.cpu,
|
|
810
|
+
env=plan.env or None,
|
|
811
|
+
egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
|
|
812
|
+
secrets=plan.secrets, # type: ignore[arg-type]
|
|
813
|
+
name=plan.name,
|
|
814
|
+
command=command,
|
|
815
|
+
idle_suspend=plan.idle_suspend,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
async def _run_detached_local(plan: _RunPlan) -> None:
|
|
820
|
+
"""Long-running local code: create an idle box, upload, background the command,
|
|
821
|
+
and print the handle. The backgrounded process is not the managed process, so
|
|
822
|
+
its output is tee'd to a file rather than the managed-process log."""
|
|
823
|
+
|
|
824
|
+
async def _use(sb: AsyncSandbox) -> int:
|
|
825
|
+
assert plan.upload is not None and plan.command is not None
|
|
826
|
+
_print_manifest(plan.upload, quiet=plan.quiet)
|
|
827
|
+
cwd = await _upload_target(sb, plan)
|
|
828
|
+
bg = (
|
|
829
|
+
f"cd {shlex.quote(cwd)} && "
|
|
830
|
+
f"nohup {plan.command} >{shlex.quote(_DETACH_LOG)} 2>&1 & echo started"
|
|
831
|
+
)
|
|
832
|
+
await sb.exec(["sh", "-c", bg])
|
|
833
|
+
typer.echo(f"running (detached): {sb.name or sb.id}")
|
|
834
|
+
typer.echo(
|
|
835
|
+
f"logs: snow sandbox logs {sb.name or sb.id} --file {_DETACH_LOG}",
|
|
836
|
+
err=True,
|
|
837
|
+
)
|
|
838
|
+
return 0
|
|
839
|
+
|
|
840
|
+
await _run_on_fresh_sandbox(
|
|
841
|
+
use=_use,
|
|
842
|
+
keep=True,
|
|
843
|
+
quiet=plan.quiet,
|
|
844
|
+
image=plan.base_image,
|
|
845
|
+
memory=plan.memory,
|
|
846
|
+
cpu=plan.cpu,
|
|
847
|
+
env=plan.env or None,
|
|
848
|
+
egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
|
|
849
|
+
secrets=plan.secrets, # type: ignore[arg-type]
|
|
850
|
+
name=plan.name,
|
|
851
|
+
idle_suspend=plan.idle_suspend,
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
async def _run_app(plan: _RunPlan, *, dry_run: bool) -> int:
|
|
856
|
+
"""App-module target: the authoring path `deploy` used to drive. Bundles the
|
|
857
|
+
module's App via `deploy_spec` and streams it. `--detach` runs it detached."""
|
|
858
|
+
from snowflake.cli_sandbox._adapter import sdk_connection
|
|
859
|
+
from snowflake.sandbox.deploy import deploy_spec as _deploy_spec
|
|
860
|
+
|
|
861
|
+
assert plan.app_path is not None
|
|
862
|
+
spec = _load_app_spec(str(plan.app_path), plan.function, plan.env)
|
|
863
|
+
|
|
864
|
+
def _on_line(stream: str, data: str) -> None:
|
|
865
|
+
typer.echo(data, err=(stream == "stderr"), nl=False)
|
|
866
|
+
|
|
867
|
+
result = await _deploy_spec(
|
|
868
|
+
spec,
|
|
869
|
+
on_line=None if dry_run else _on_line,
|
|
870
|
+
dry_run=dry_run,
|
|
871
|
+
run_preflight=True,
|
|
872
|
+
detach=plan.detach,
|
|
873
|
+
connection=sdk_connection(),
|
|
874
|
+
)
|
|
875
|
+
_print_preflight_findings(result)
|
|
876
|
+
if dry_run:
|
|
877
|
+
_print_app_plan(result) # raises Exit(0)
|
|
878
|
+
if plan.detach:
|
|
879
|
+
typer.echo(f"running (detached): {result.name or result.sandbox_id}")
|
|
880
|
+
return 0
|
|
881
|
+
if result.diagnosis:
|
|
882
|
+
typer.echo(f"diagnosis: {result.diagnosis}", err=True)
|
|
883
|
+
return result.exit_code or 0
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _run_command(
|
|
887
|
+
targets: list[str] | None = typer.Argument(
|
|
888
|
+
None,
|
|
889
|
+
metavar="[TARGET]...",
|
|
890
|
+
help="Local paths to run: files and/or directories, repeatable. Never an "
|
|
891
|
+
"image reference -- pass images as --image. Omit to run --image (or the "
|
|
892
|
+
"default image) directly, or use --app for an App module.",
|
|
893
|
+
),
|
|
894
|
+
app: str | None = typer.Option(
|
|
895
|
+
None,
|
|
896
|
+
"--app",
|
|
897
|
+
metavar="MODULE.py",
|
|
898
|
+
help="Deploy a .py module that declares an App, via the SDK authoring path. "
|
|
899
|
+
"Explicit: a plain script is never treated as an App because of its contents.",
|
|
900
|
+
),
|
|
901
|
+
command: str | None = typer.Option(
|
|
902
|
+
None,
|
|
903
|
+
"--command",
|
|
904
|
+
"--cmd",
|
|
905
|
+
metavar="CMD",
|
|
906
|
+
help="Command to run after the code is in place (streamed, exits with its "
|
|
907
|
+
"code). Overrides extension detection; required for a directory, an "
|
|
908
|
+
"unrecognized file type, and for a TARGET-less run unless --detach runs the "
|
|
909
|
+
"image's own entrypoint. Not valid for an App module. `--cmd` is an accepted "
|
|
910
|
+
"alias (same flag on `shell`).",
|
|
911
|
+
),
|
|
912
|
+
detach: bool = typer.Option(
|
|
913
|
+
False,
|
|
914
|
+
"--detach",
|
|
915
|
+
"-d",
|
|
916
|
+
help="Create the sandbox and leave it running instead of running to "
|
|
917
|
+
"completion: wait for it to spin up and the command to start, then print "
|
|
918
|
+
"the handle and return. (Supervised restart across platform interruptions "
|
|
919
|
+
"is a separate, not-yet-available feature.)",
|
|
920
|
+
),
|
|
921
|
+
dry_run: bool = typer.Option(
|
|
922
|
+
False,
|
|
923
|
+
"--dry-run",
|
|
924
|
+
help="Print the resolved plan and exit without creating anything.",
|
|
925
|
+
),
|
|
926
|
+
function: str | None = typer.Option(
|
|
927
|
+
None,
|
|
928
|
+
"--function",
|
|
929
|
+
"-f",
|
|
930
|
+
metavar="NAME",
|
|
931
|
+
help="For an App module: which @app.function to run (default: the first). "
|
|
932
|
+
"Errors on any other target.",
|
|
933
|
+
),
|
|
934
|
+
envs: list[str] | None = typer.Option(
|
|
935
|
+
None,
|
|
936
|
+
"--env",
|
|
937
|
+
"-e",
|
|
938
|
+
metavar="KEY=VAL",
|
|
939
|
+
help=_ENV_HELP,
|
|
940
|
+
),
|
|
941
|
+
image: str | None = typer.Option(
|
|
942
|
+
None,
|
|
943
|
+
"--image",
|
|
944
|
+
metavar="IMAGE",
|
|
945
|
+
help="The image to run. With no TARGET it IS the workload; with local "
|
|
946
|
+
"TARGETs it is the base image that code runs on. Default: the deployment's "
|
|
947
|
+
"default runtime image. Invalid with --app (the App declares it).",
|
|
948
|
+
),
|
|
949
|
+
include: list[str] | None = typer.Option(
|
|
950
|
+
None,
|
|
951
|
+
"--include",
|
|
952
|
+
metavar="GLOB",
|
|
953
|
+
help="Re-admit paths an exclusion dropped; repeatable, applied after every "
|
|
954
|
+
"--exclude. gitignore syntax, relative to each TARGET. Cannot resurrect a "
|
|
955
|
+
"credential-shaped file.",
|
|
956
|
+
),
|
|
957
|
+
exclude: list[str] | None = typer.Option(
|
|
958
|
+
None,
|
|
959
|
+
"--exclude",
|
|
960
|
+
metavar="GLOB",
|
|
961
|
+
help="Skip matching paths; repeatable. gitignore syntax relative to each "
|
|
962
|
+
"TARGET, so `node_modules/` and `data/` work as written.",
|
|
963
|
+
),
|
|
964
|
+
allow_credential_file: list[str] | None = typer.Option(
|
|
965
|
+
None,
|
|
966
|
+
"--allow-credential-file",
|
|
967
|
+
metavar="PATH",
|
|
968
|
+
help="Upload one credential-shaped file (.env, *.pem, id_rsa, ...) that is "
|
|
969
|
+
"excluded by default. Exact path only, never a glob; repeatable.",
|
|
970
|
+
),
|
|
971
|
+
eai: list[str] | None = typer.Option(
|
|
972
|
+
None,
|
|
973
|
+
"--eai",
|
|
974
|
+
metavar="NAME",
|
|
975
|
+
help=EAI_HELP,
|
|
976
|
+
),
|
|
977
|
+
secret: list[str] | None = typer.Option(
|
|
978
|
+
None,
|
|
979
|
+
"--secret",
|
|
980
|
+
metavar="ENV_VAR=DB.SCHEMA.NAME",
|
|
981
|
+
help=SECRET_HELP,
|
|
982
|
+
),
|
|
983
|
+
no_default_egress: bool = typer.Option(
|
|
984
|
+
False,
|
|
985
|
+
"--no-default-egress",
|
|
986
|
+
help=NO_DEFAULT_EGRESS_HELP,
|
|
987
|
+
),
|
|
988
|
+
allow_large: bool = typer.Option(
|
|
989
|
+
False,
|
|
990
|
+
"--allow-large",
|
|
991
|
+
help="Proceed with an upload over the size or file-count limit. /tmp is "
|
|
992
|
+
"RAM-backed, so a large upload competes with the workload for --memory.",
|
|
993
|
+
),
|
|
994
|
+
memory: str | None = typer.Option(
|
|
995
|
+
None,
|
|
996
|
+
"--memory",
|
|
997
|
+
metavar="TIER",
|
|
998
|
+
help="Memory tier: 1g/4g/8g/16g/32g/64g (default: 4g).",
|
|
999
|
+
),
|
|
1000
|
+
cpu: float | None = typer.Option(
|
|
1001
|
+
None,
|
|
1002
|
+
"--cpu",
|
|
1003
|
+
metavar="CORES",
|
|
1004
|
+
help="CPU cores (0.25-8.0).",
|
|
1005
|
+
),
|
|
1006
|
+
idle_suspend: str | None = typer.Option(
|
|
1007
|
+
None,
|
|
1008
|
+
"--idle-suspend",
|
|
1009
|
+
metavar="DURATION",
|
|
1010
|
+
help="Idle-suspend timeout (e.g. 30m/2h) for a --detach image or "
|
|
1011
|
+
"local-code run (default: 24h).",
|
|
1012
|
+
),
|
|
1013
|
+
name: str | None = typer.Option(
|
|
1014
|
+
None,
|
|
1015
|
+
"--name",
|
|
1016
|
+
metavar="NAME",
|
|
1017
|
+
help="Name for a --detach image or local-code sandbox (unique per owner).",
|
|
1018
|
+
),
|
|
1019
|
+
quiet: bool = typer.Option(
|
|
1020
|
+
False,
|
|
1021
|
+
"--quiet",
|
|
1022
|
+
"-q",
|
|
1023
|
+
help="Suppress the status lines on stderr; the command's own output is unaffected.",
|
|
1024
|
+
),
|
|
1025
|
+
use_snow_connection: bool = typer.Option(
|
|
1026
|
+
True,
|
|
1027
|
+
"--use-snow-connection/--no-snow-connection",
|
|
1028
|
+
help=_USE_SNOW_CONN_HELP,
|
|
1029
|
+
),
|
|
1030
|
+
connection: str | None = typer.Option(
|
|
1031
|
+
None,
|
|
1032
|
+
"--connection",
|
|
1033
|
+
"-c",
|
|
1034
|
+
help=_CONN_HELP,
|
|
1035
|
+
),
|
|
1036
|
+
**options: Any,
|
|
1037
|
+
) -> None:
|
|
1038
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
1039
|
+
|
|
1040
|
+
plan = _plan_from_args(
|
|
1041
|
+
targets=targets,
|
|
1042
|
+
app=app,
|
|
1043
|
+
command=command,
|
|
1044
|
+
detach=detach,
|
|
1045
|
+
envs=envs,
|
|
1046
|
+
image=image,
|
|
1047
|
+
memory=memory,
|
|
1048
|
+
cpu=cpu,
|
|
1049
|
+
idle_suspend=idle_suspend,
|
|
1050
|
+
name=name,
|
|
1051
|
+
function=function,
|
|
1052
|
+
include=include,
|
|
1053
|
+
exclude=exclude,
|
|
1054
|
+
allow_credential_file=allow_credential_file,
|
|
1055
|
+
allow_large=allow_large,
|
|
1056
|
+
eai=eai,
|
|
1057
|
+
secret=secret,
|
|
1058
|
+
no_default_egress=no_default_egress,
|
|
1059
|
+
quiet=quiet,
|
|
1060
|
+
)
|
|
1061
|
+
_hint_app_module(plan)
|
|
1062
|
+
|
|
1063
|
+
# An inferred dry-run creates nothing, so it needs no live connection.
|
|
1064
|
+
if dry_run and plan.kind != "app":
|
|
1065
|
+
_print_inferred_plan(plan) # raises Exit(0)
|
|
1066
|
+
|
|
1067
|
+
# No dry run connects -- `_apply_connection` below is passed `not dry_run` -- so
|
|
1068
|
+
# announcing a connection for one was simply untrue.
|
|
1069
|
+
if use_snow_connection and not dry_run:
|
|
1070
|
+
where = f" ({connection})" if connection else ""
|
|
1071
|
+
_emit_status(f"Connecting to Snowflake{where}...", quiet=quiet)
|
|
1072
|
+
# An App dry-run resolves the plan locally too; only a live run needs credentials.
|
|
1073
|
+
_apply_connection(use_snow_connection and not dry_run, connection=connection)
|
|
1074
|
+
|
|
1075
|
+
try:
|
|
1076
|
+
if plan.kind == "app":
|
|
1077
|
+
code = asyncio.run(_run_app(plan, dry_run=dry_run))
|
|
1078
|
+
raise typer.Exit(code)
|
|
1079
|
+
if not plan.detach:
|
|
1080
|
+
code = asyncio.run(_run_job(plan))
|
|
1081
|
+
_hint_missing_interpreter(plan, code)
|
|
1082
|
+
raise typer.Exit(code)
|
|
1083
|
+
if plan.kind == "image":
|
|
1084
|
+
asyncio.run(_run_detached_image(plan))
|
|
1085
|
+
else:
|
|
1086
|
+
asyncio.run(_run_detached_local(plan))
|
|
1087
|
+
except KeyboardInterrupt:
|
|
1088
|
+
raise typer.Exit(130) from None
|
|
1089
|
+
except SandboxError as exc:
|
|
1090
|
+
typer.echo(f"error: {exc}", err=True)
|
|
1091
|
+
raise typer.Exit(1) from exc
|