snowflake-sandbox-python 0.2.2a3__py3-none-any.whl → 0.2.2a5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. snowflake/cli_sandbox/_egress_flags.py +6 -1
  2. snowflake/cli_sandbox/_lifecycle_command.py +108 -0
  3. snowflake/cli_sandbox/_run_command.py +7 -1
  4. snowflake/cli_sandbox/_upload_plan.py +1 -1
  5. snowflake/cli_sandbox/commands.py +53 -5
  6. snowflake/sandbox/__init__.py +2 -0
  7. snowflake/sandbox/_bundle.py +2 -2
  8. snowflake/sandbox/_code_delivery.py +88 -0
  9. snowflake/sandbox/_diagnostics.py +8 -6
  10. snowflake/sandbox/_env.py +1 -1
  11. snowflake/sandbox/_preview.py +164 -0
  12. snowflake/sandbox/_runtime/_shims.py +32 -4
  13. snowflake/sandbox/_sandbox_state.py +5 -0
  14. snowflake/sandbox/_upload_plan.py +1 -1
  15. snowflake/sandbox/_version.py +1 -1
  16. snowflake/sandbox/_wire.py +64 -6
  17. snowflake/sandbox/client.py +389 -270
  18. snowflake/sandbox/connect.py +1 -1
  19. snowflake/sandbox/deploy.py +113 -22
  20. snowflake/sandbox/egress.py +114 -40
  21. snowflake/sandbox/exceptions.py +40 -5
  22. snowflake/sandbox/image.py +545 -18
  23. snowflake/sandbox/jobs.py +54 -34
  24. snowflake/sandbox/session_app.py +16 -1
  25. snowflake/sandbox/sync_client.py +389 -266
  26. snowflake/sandbox/warm_session.py +3 -3
  27. {snowflake_sandbox_python-0.2.2a3.dist-info → snowflake_sandbox_python-0.2.2a5.dist-info}/METADATA +1 -1
  28. {snowflake_sandbox_python-0.2.2a3.dist-info → snowflake_sandbox_python-0.2.2a5.dist-info}/RECORD +32 -29
  29. {snowflake_sandbox_python-0.2.2a3.dist-info → snowflake_sandbox_python-0.2.2a5.dist-info}/WHEEL +0 -0
  30. {snowflake_sandbox_python-0.2.2a3.dist-info → snowflake_sandbox_python-0.2.2a5.dist-info}/entry_points.txt +0 -0
  31. {snowflake_sandbox_python-0.2.2a3.dist-info → snowflake_sandbox_python-0.2.2a5.dist-info}/licenses/LICENSE +0 -0
  32. {snowflake_sandbox_python-0.2.2a3.dist-info → snowflake_sandbox_python-0.2.2a5.dist-info}/top_level.txt +0 -0
@@ -106,7 +106,12 @@ def build_egress(
106
106
 
107
107
  egress = Egress(
108
108
  external_access_integrations=integrations,
109
- **({"allow_default_egress": False} if no_default_egress else {}),
109
+ # Uses the current parameter name. The CLI FLAG stays `--no-default-egress`: it is
110
+ # released, renaming it would break users' scripts, and that is a separate decision
111
+ # from the library parameter's name. So the flag spelling and the parameter spelling
112
+ # differ here deliberately — the alternative is the CLI depending on a deprecated
113
+ # parameter, which is worse.
114
+ **({"allow_public_package_registries": False} if no_default_egress else {}),
110
115
  )
111
116
  # Validate now, not at create time. `compile_egress` is pure and is what the client
112
117
  # runs anyway -- but it runs *after* connecting, so a mistyped host pair otherwise
@@ -0,0 +1,108 @@
1
+ """``snow sandbox suspend`` and ``snow sandbox resume``.
2
+
3
+ The CLI counterparts to ``Sandbox.suspend()`` / ``Sandbox.resume()``. Snowflake
4
+ suspends an idle sandbox on its own timer; these let an operator bank a sandbox's
5
+ state on demand, and wake one without running anything in it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import typer
13
+
14
+ # From _common, where these are DEFINED. Importing them via _get_command (which
15
+ # re-exports them) is an implicit re-export and mypy rejects it.
16
+ from snowflake.cli_sandbox._common import (
17
+ _CONN_HELP,
18
+ _USE_SNOW_CONN_HELP,
19
+ _apply_connection,
20
+ _resolve_sandbox_id,
21
+ )
22
+
23
+
24
+ def _suspend_command(
25
+ sandbox: str = typer.Argument(..., help="Sandbox name (or id) to suspend."),
26
+ use_snow_connection: bool = typer.Option(
27
+ True,
28
+ "--use-snow-connection/--no-snow-connection",
29
+ help=_USE_SNOW_CONN_HELP,
30
+ ),
31
+ connection: str | None = typer.Option(
32
+ None,
33
+ "--connection",
34
+ "-c",
35
+ help=_CONN_HELP,
36
+ ),
37
+ **options: Any,
38
+ ) -> None:
39
+ """Suspend a sandbox: snapshot its filesystem and tear the container down.
40
+
41
+ The sandbox keeps its files; the next thing that touches it brings it back on
42
+ that snapshot with a fresh process tree. Already-suspended is success.
43
+
44
+ Example:
45
+ snow sandbox suspend SANDBOX
46
+ """
47
+ from snowflake.cli_sandbox._adapter import sdk_connection
48
+ from snowflake.sandbox.exceptions import SandboxError
49
+ from snowflake.sandbox.sync_client import get_sandbox
50
+
51
+ _apply_connection(use_snow_connection, connection)
52
+ try:
53
+ # The SYNC client: AsyncSandbox.connect is itself a coroutine, so a bare
54
+ # asyncio.run(sb.suspend()) on its result would fail on a coroutine object.
55
+ # get_sandbox(), not Sandbox.connect(): _resolve_sandbox_id already turned a
56
+ # name into a cntr_ id, and connect() resolves its argument as a NAME -- so
57
+ # feeding it an id fails with "no sandbox named cntr_...".
58
+ #
59
+ # The SYNC client throughout: AsyncSandbox.connect is itself a coroutine, so a
60
+ # bare asyncio.run(sb.suspend()) on its result fails on a coroutine object.
61
+ get_sandbox(_resolve_sandbox_id(sandbox), connection=sdk_connection()).suspend()
62
+ except SandboxError as exc:
63
+ typer.echo(f"error: {exc}", err=True)
64
+ raise typer.Exit(1) from exc
65
+ typer.echo(f"suspended {sandbox}")
66
+
67
+
68
+ def _resume_command(
69
+ sandbox: str = typer.Argument(..., help="Sandbox name (or id) to resume."),
70
+ use_snow_connection: bool = typer.Option(
71
+ True,
72
+ "--use-snow-connection/--no-snow-connection",
73
+ help=_USE_SNOW_CONN_HELP,
74
+ ),
75
+ connection: str | None = typer.Option(
76
+ None,
77
+ "--connection",
78
+ "-c",
79
+ help=_CONN_HELP,
80
+ ),
81
+ **options: Any,
82
+ ) -> None:
83
+ """Wake a suspended sandbox without running anything in it.
84
+
85
+ Rarely needed -- anything that touches a sandbox resumes it anyway -- so this
86
+ is for paying the cold start up front. Returns once the resume is under way,
87
+ not once the sandbox is serving.
88
+
89
+ Example:
90
+ snow sandbox resume SANDBOX
91
+ """
92
+ from snowflake.cli_sandbox._adapter import sdk_connection
93
+ from snowflake.sandbox.exceptions import SandboxError
94
+ from snowflake.sandbox.sync_client import get_sandbox
95
+
96
+ _apply_connection(use_snow_connection, connection)
97
+ try:
98
+ # get_sandbox(), not Sandbox.connect(): _resolve_sandbox_id already turned a
99
+ # name into a cntr_ id, and connect() resolves its argument as a NAME.
100
+ get_sandbox(_resolve_sandbox_id(sandbox), connection=sdk_connection()).resume()
101
+ except SandboxError as exc:
102
+ typer.echo(f"error: {exc}", err=True)
103
+ raise typer.Exit(1) from exc
104
+ typer.echo(f"resuming {sandbox}")
105
+
106
+
107
+ _SUSPEND_HELP = "Suspend a sandbox, snapshotting its filesystem.\n\nExample:\n snow sandbox suspend SANDBOX\n"
108
+ _RESUME_HELP = "Resume a suspended sandbox.\n\nExample:\n snow sandbox resume SANDBOX\n"
@@ -312,7 +312,13 @@ def _print_inferred_plan(plan: _RunPlan) -> None:
312
312
  typer.echo(f"command: {plan.command or '(image default)'}")
313
313
  if plan.egress is not None:
314
314
  eais = getattr(plan.egress, "external_access_integrations", ())
315
- closed = getattr(plan.egress, "allow_default_egress", None) is False
315
+ # Read the RESOLVER, not any one spelling. There are three accepted names for this
316
+ # control and reading a specific field goes silently blind when a caller (or this
317
+ # CLI) uses a different one — which is exactly what happened: switching the flag's
318
+ # construction to allow_public_package_registries made this summary stop reporting
319
+ # "default-egress=closed" while the sandbox was still correctly closed. A dry run
320
+ # that under-reports the egress posture is worse than no dry run.
321
+ closed = getattr(plan.egress, "default_egress_allowed", None) is False
316
322
  bits = [f"eai={','.join(eais)}" if eais else "", "default-egress=closed" if closed else ""]
317
323
  if plan.secrets:
318
324
  bits.append(f"secrets={len(plan.secrets)}")
@@ -2,7 +2,7 @@
2
2
 
3
3
  The *selection* engine (the walk, the skip reasons, `UploadPlan`) lives in
4
4
  `snowflake.sandbox._upload_plan`, because the library needs it too: `Sandbox.upload_dir`
5
- and the `from_local` bundle are the same decision about the same trees. What stays here
5
+ and create-time bundle assembly are the same decision about the same trees. What stays here
6
6
  is the part that is genuinely about a command line — turning argv tokens into targets,
7
7
  and the errors that name CLI flags in their remediation.
8
8
 
@@ -26,6 +26,12 @@ from snowflake.cli_sandbox._common import (
26
26
  _resolve_sandbox_id,
27
27
  )
28
28
  from snowflake.cli_sandbox._get_command import _CANCEL_HELP, _cancel_command, get
29
+ from snowflake.cli_sandbox._lifecycle_command import (
30
+ _RESUME_HELP,
31
+ _SUSPEND_HELP,
32
+ _resume_command,
33
+ _suspend_command,
34
+ )
29
35
  from snowflake.cli_sandbox._run_command import _RUN_HELP, _run_command
30
36
  from snowflake.cli_sandbox._shell_command import ( # noqa: F401 (re-export: import surface)
31
37
  _CREATE_RETRY_ATTEMPTS,
@@ -279,10 +285,15 @@ async def _stream_process_logs(sb: Any, tail: int) -> None:
279
285
 
280
286
  _LOGS_HELP = (
281
287
  "Print or follow a sandbox's output.\n\n"
282
- "By default this is the captured stdout/stderr of the sandbox's managed process, "
283
- "which only exists for command containers (created with `command=`). Snapshot by "
284
- "default; --follow tails the SAME source live until the process finishes, printing "
285
- "stdout to stdout and stderr to stderr so `2>` separates them.\n\n"
288
+ "By default this is the server's `all`: the container's own output plus the managed "
289
+ "process's, where a command container has one. Snapshot by "
290
+ "default; --follow instead tails the MANAGED process live until it finishes, printing "
291
+ "stdout to stdout and stderr to stderr so `2>` separates them. That is the only source "
292
+ "it can follow, which is why --source is snapshot-only.\n\n"
293
+ "--source reads a different captured stream: `exec` is what `exec()` calls printed, "
294
+ "kept per producer by guest-init; `base` is the container's own output; `all` is base "
295
+ "+ managed, and deliberately NOT exec, since an exec's output is already returned by "
296
+ "the call that produced it. --pid narrows `exec` to one producer.\n\n"
286
297
  "--file reads a file inside the sandbox instead. That is how you read a "
287
298
  "`run --detach` local-code run: its command is backgrounded rather than being the "
288
299
  "managed process, so its output is tee'd to a file and the managed-process log is "
@@ -291,6 +302,8 @@ _LOGS_HELP = (
291
302
  " snow sandbox logs SANDBOX_ID\n"
292
303
  " snow sandbox logs SANDBOX_ID --tail 50\n"
293
304
  " snow sandbox logs SANDBOX_ID --follow\n"
305
+ " snow sandbox logs SANDBOX_ID --source exec\n"
306
+ " snow sandbox logs SANDBOX_ID --source exec --pid 295\n"
294
307
  " snow sandbox logs SANDBOX_ID --file /tmp/sandbox-run.out --follow"
295
308
  )
296
309
 
@@ -318,6 +331,22 @@ def _logs_command(
318
331
  metavar="MS",
319
332
  help="Only lines at or after this server timestamp (Unix epoch ms). Ignored with --follow.",
320
333
  ),
334
+ source: str | None = typer.Option(
335
+ None,
336
+ "--source",
337
+ metavar="WHICH",
338
+ help="Which output to read: all (the default when omitted: base + managed, and it does "
339
+ "NOT include exec), managed (just the command= process), exec (what `exec()` calls "
340
+ "printed), or base (the container's own stdout/stderr). Snapshot only — --follow tails "
341
+ "the managed process and cannot follow another source.",
342
+ ),
343
+ pid: int = typer.Option(
344
+ 0,
345
+ "--pid",
346
+ metavar="N",
347
+ help="With --source exec, read only this producer instead of every exec merged "
348
+ "by capture time. The pid is shown per line in the merged output.",
349
+ ),
321
350
  file: str | None = typer.Option(
322
351
  None,
323
352
  "--file",
@@ -347,6 +376,8 @@ def _logs_command(
347
376
  Example:
348
377
  snow sandbox logs SANDBOX_ID --tail 50
349
378
  snow sandbox logs SANDBOX_ID --follow
379
+ snow sandbox logs SANDBOX_ID --source exec
380
+ snow sandbox logs SANDBOX_ID --source exec --pid 295
350
381
  snow sandbox logs SANDBOX_ID --file /tmp/sandbox-run.out
351
382
  """
352
383
  import shlex
@@ -360,6 +391,19 @@ def _logs_command(
360
391
  "--since applies to the managed-process log, not to --file (a file has no "
361
392
  "server timestamps). Use --tail to limit the output instead."
362
393
  )
394
+ # Each of these would otherwise be accepted and then silently ignored, which is worse
395
+ # than a rejection: the caller reads output and believes it is what they asked for.
396
+ if source is not None and file is not None:
397
+ raise typer.BadParameter(
398
+ "--source selects a captured stream; --file reads a path. Pass one."
399
+ )
400
+ if source is not None and follow and source not in ("managed", "process"):
401
+ raise typer.BadParameter(
402
+ f"--follow tails the managed process and cannot follow --source {source}. "
403
+ "Drop --follow for a snapshot, or drop --source to follow the managed process."
404
+ )
405
+ if pid and source != "exec":
406
+ raise typer.BadParameter("--pid selects one exec producer, so it needs --source exec.")
363
407
 
364
408
  _apply_connection(use_snow_connection, connection)
365
409
 
@@ -373,7 +417,7 @@ def _logs_command(
373
417
  elif follow:
374
418
  await _stream_process_logs(sb, tail)
375
419
  else:
376
- typer.echo(await sb.logs(tail=tail, since_ts_ms=since))
420
+ typer.echo(await sb.logs(tail=tail, since_ts_ms=since, source=source, pid=pid))
377
421
 
378
422
  async def _read_sandbox_file(sb: Any, path: str, *, tail: int, follow: bool) -> None:
379
423
  """Print, or tail, a file inside the sandbox.
@@ -554,3 +598,7 @@ app.command(name="delete", requires_connection=False, help=_DELETE_HELP)(_delete
554
598
  # so these are plain top-level verbs alongside `list`/`logs`/`delete`.
555
599
  app.command(name="get", requires_connection=False)(get)
556
600
  app.command(name="cancel", requires_connection=False, help=_CANCEL_HELP)(_cancel_command)
601
+ # The two halves of the suspend/resume pair. Snowflake suspends an idle sandbox on its
602
+ # own timer; these make both directions available on demand.
603
+ app.command(name="suspend", requires_connection=False, help=_SUSPEND_HELP)(_suspend_command)
604
+ app.command(name="resume", requires_connection=False, help=_RESUME_HELP)(_resume_command)
@@ -28,6 +28,7 @@ from typing import TYPE_CHECKING, Any
28
28
  from snowflake.sandbox._version import __version__
29
29
  from snowflake.sandbox.exceptions import (
30
30
  SandboxAuthError,
31
+ SandboxBuildError,
31
32
  SandboxConflictError,
32
33
  SandboxContractWarning,
33
34
  SandboxCreateError,
@@ -124,6 +125,7 @@ __all__ = [
124
125
  "SandboxCreateError",
125
126
  "SandboxFailure",
126
127
  "SandboxValidationError",
128
+ "SandboxBuildError",
127
129
  "SandboxConflictError",
128
130
  "SandboxNotFoundError",
129
131
  "SandboxNotReadyError",
@@ -75,7 +75,7 @@ def _collect_tree(
75
75
  include: list[str] | None = None,
76
76
  ) -> tuple[list[tuple[str, bytes]], UploadPlan]:
77
77
  """Sorted ``(relpath, content)`` for every file under *src*, plus the plan that
78
- says what was left out and why. Used by ``from_local``.
78
+ says what was left out and why. Used by create-time bundle assembly.
79
79
 
80
80
  Selection is delegated to `_upload_plan`, which is the one walk the CLI and
81
81
  `Sandbox.upload_dir` also use. This function used to own a second, weaker walk:
@@ -123,7 +123,7 @@ def _collect_tree(
123
123
  raise SandboxValidationError(
124
124
  f"no files to bundle from {src}: all {len(plan.skipped)} path(s) were "
125
125
  f"filtered out ({_reason_summary(plan)}). Pass include= to re-admit a "
126
- f"filtered path, or point from_local at the directory holding your code."
126
+ f"filtered path, or point create(code=...) at the directory holding your code."
127
127
  )
128
128
  return files, plan
129
129
 
@@ -0,0 +1,88 @@
1
+ """Create-time code delivery via ``upload_dir`` (no stage / no bootstrap).
2
+
3
+ Deploy and ``Sandbox.create(code=)`` create an empty sandbox, upload the local
4
+ tree into ``CODE_DIR``, then signal a ready marker so a wait-then-exec main
5
+ command (detach / jobs / ``create(code=, command=…)``) can start the entry.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shlex
11
+ from collections.abc import Sequence
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from snowflake.sandbox.client import AsyncSandbox
16
+ from snowflake.sandbox.sync_client import Sandbox
17
+
18
+ __all__ = [
19
+ "CODE_DIR",
20
+ "READY_MARKER",
21
+ "cd_into_code_dir",
22
+ "entry_in_code_dir",
23
+ "signal_bundle_ready",
24
+ "signal_bundle_ready_sync",
25
+ "upload_bundle",
26
+ "upload_bundle_sync",
27
+ "wait_then_exec",
28
+ ]
29
+
30
+ # Where ``upload_dir`` lands the project. Relative entries ``cd`` here before exec.
31
+ CODE_DIR = "/tmp/_sandbox_code"
32
+ READY_MARKER = f"{CODE_DIR}/.sandbox_bundle_ready"
33
+
34
+ _CD_INTO_CODE_DIR = f"cd {shlex.quote(CODE_DIR)} && "
35
+ # A copy of this name inside the uploaded tree must not count as "ready" —
36
+ # the marker is written only after upload_dir returns.
37
+ _MARKER_EXCLUDE = [".sandbox_bundle_ready", "**/.sandbox_bundle_ready"]
38
+
39
+
40
+ def cd_into_code_dir() -> str:
41
+ """Shell fragment that cds into ``CODE_DIR`` and stops the command if that fails."""
42
+ return _CD_INTO_CODE_DIR
43
+
44
+
45
+ def entry_in_code_dir(entry: Sequence[str]) -> list[str]:
46
+ """Wrap *entry* so it runs with cwd=``CODE_DIR`` after ``upload_dir``."""
47
+ inner = " ".join(shlex.quote(str(a)) for a in entry)
48
+ return ["/bin/bash", "-c", _CD_INTO_CODE_DIR + "exec " + inner]
49
+
50
+
51
+ def wait_then_exec(entry: Sequence[str]) -> list[str]:
52
+ """Container main command: block until the ready marker exists, then exec *entry*.
53
+
54
+ Used when the entry must be the managed process (detach / jobs / ``create(code=)``)
55
+ but the bundle can only be uploaded after the sandbox is up.
56
+ """
57
+ inner = " ".join(shlex.quote(str(a)) for a in entry)
58
+ ready = shlex.quote(READY_MARKER)
59
+ body = f"while [ ! -f {ready} ]; do sleep 0.1; done; " + _CD_INTO_CODE_DIR + "exec " + inner
60
+ return ["/bin/bash", "-c", body]
61
+
62
+
63
+ def _signal_script() -> str:
64
+ return f"mkdir -p {shlex.quote(CODE_DIR)} && touch {shlex.quote(READY_MARKER)}"
65
+
66
+
67
+ async def upload_bundle(sb: AsyncSandbox, local_dir: str) -> None:
68
+ """Upload *local_dir* into ``CODE_DIR`` on a live sandbox.
69
+
70
+ The ready-marker name is excluded so a file in the tree cannot release a
71
+ ``wait_then_exec`` main process before the upload finishes.
72
+ """
73
+ await sb.upload_dir(local_dir, CODE_DIR, exclude=_MARKER_EXCLUDE)
74
+
75
+
76
+ def upload_bundle_sync(sb: Sandbox, local_dir: str) -> None:
77
+ """Sync counterpart of `upload_bundle`."""
78
+ sb.upload_dir(local_dir, CODE_DIR, exclude=_MARKER_EXCLUDE)
79
+
80
+
81
+ async def signal_bundle_ready(sb: AsyncSandbox) -> None:
82
+ """Write the ready marker so a `wait_then_exec` main process can proceed."""
83
+ await sb.exec(["/bin/bash", "-c", _signal_script()], timeout=60.0)
84
+
85
+
86
+ def signal_bundle_ready_sync(sb: Sandbox) -> None:
87
+ """Sync counterpart of `signal_bundle_ready`."""
88
+ sb.exec(["/bin/bash", "-c", _signal_script()], timeout=60.0)
@@ -127,19 +127,19 @@ def preflight_checks(spec: DeploySpec, bundle_root: Path | None = None) -> list[
127
127
  problems.append(Problem("error", str(exc)))
128
128
 
129
129
  # A secret's `host` and Snowflake reachability are different controls: the first is
130
- # enforced at the credential swap, the second by allow_internet / the group
131
- # booleans. A secret scoped to a host the sandbox cannot reach on Snowflake is
130
+ # enforced at the credential swap, the second by the egress level
131
+ # (allow_public_package_registries) / the group booleans. A secret scoped to a host the sandbox cannot reach on Snowflake is
132
132
  # silently useless, so say so. (eai_hosts is the SPCS perimeter and does not
133
133
  # affect Snowflake reachability, so it is deliberately not consulted here.)
134
134
  # Only warn when egress was *explicitly* closed AND no EAI was granted:
135
- # - allow_internet is tri-state: left unset the platform applies its permissive
135
+ # - the egress level is tri-state: left unset the platform applies its permissive
136
136
  # default, and actual reachability is then an Istio question we cannot answer
137
137
  # from here — warning on it would be noise on the common path.
138
138
  # - allow_github / allow_pypi are NOT credited here, and now never could be: both
139
139
  # are inert, so neither makes a host reachable in any mode. The retired
140
140
  # allowed_egress_hosts is likewise not consulted — hosts named there are ignored
141
141
  # platform-side.
142
- # - Under allow_internet=False the only grant left is an EAI, whose network rules
142
+ # - Under allow_public_package_registries=False the only grant left is an EAI, whose rules
143
143
  # live in Snowflake and cannot be resolved from here — so an EAI being present is
144
144
  # taken as "the caller has said how" (loop-invariant, hence hoisted out below).
145
145
  eg = spec.egress
@@ -153,13 +153,15 @@ def preflight_checks(spec: DeploySpec, bundle_root: Path | None = None) -> list[
153
153
  hint = (
154
154
  f"grant {h!r} with an External Access Integration and pass "
155
155
  f'external_access_integrations=("MY_EAI",) — under '
156
- f"allow_internet=False that is the only grant the platform applies"
156
+ f"allow_public_package_registries=False that is the only grant the "
157
+ f"platform applies"
157
158
  )
158
159
  problems.append(
159
160
  Problem(
160
161
  "warn",
161
162
  f"secret {s.fqn!r} is scoped to host {h!r}, which the sandbox cannot "
162
- f"reach with allow_internet=False — the credential could never be used",
163
+ f"reach with allow_public_package_registries=False — the credential "
164
+ f"could never be used",
163
165
  hint,
164
166
  )
165
167
  )
snowflake/sandbox/_env.py CHANGED
@@ -117,7 +117,7 @@ def validate_env_mapping(
117
117
  ``str(False)`` coercion is a footgun — ``env(DEBUG=False)`` would bake the
118
118
  string ``"False"``, which is truthy). *allow* names keys that are exempt
119
119
  from the reserved check because the SDK sets them itself (e.g.
120
- ``SNOWFLAKE_CODE_URL``, the presigned-download URL ``from_local`` injects).
120
+ legacy ``SNOWFLAKE_CODE_URL`` stage bootstrap, now unused).
121
121
  Returns a plain ``dict[str, str]``. Raises `SandboxError`.
122
122
  """
123
123
  if not env:
@@ -0,0 +1,164 @@
1
+ """Resolve the stable browser URL for a port inside a sandbox.
2
+
3
+ The URL is ``<handle>-<port>-sandbox.<suffix>``, where ``handle`` is the sandbox id
4
+ without its ``cntr_`` prefix and ``suffix`` is the account's ``*.snowflake.app``
5
+ host suffix. Both halves of the leftmost label are opaque to the edge: the CNG
6
+ ingress proxy parses them out of the hostname, so any port works with no
7
+ registration step.
8
+
9
+ The suffix is NOT assembled here. It comes from
10
+ ``SYSTEM$GET_SNOWFLAKE_APP_HOST_SUFFIX()``, which resolves the region id and the
11
+ TLD the way CNG app hosts do -- including ``.snowflakeapp.cn`` on a China
12
+ deployment and a deployment-level form for accounts without per-account app URLs.
13
+ A client-side guess does not fail loudly on those; it produces a confident wrong
14
+ hostname.
15
+
16
+ SQL, not REST: the sandbox REST surface accepts the connector's session token, but
17
+ ``/api/v2/statements`` requires ``Bearer`` and rejects it (390146). So this takes
18
+ the same route ``list_mcp_servers`` does -- a connector connection resolved from
19
+ the transport -- and is a plain sync function for the same reason: both the async
20
+ and sync clients call it directly.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ from snowflake.sandbox.exceptions import SandboxError, SandboxValidationError
28
+
29
+ __all__ = ["app_host_suffix", "preview_url", "validate_preview_port"]
30
+
31
+ # A DNS label caps at 63 octets and the first label is "<handle>-<port>-sandbox",
32
+ # so the proxy bounds app_key at 63 - len("-sandbox"). Mirrors sandbox-api's
33
+ # ingress/resolver.go maxAppKey.
34
+ _MAX_APP_KEY = 63 - len("-sandbox")
35
+
36
+ _CONTAINER_ID_PREFIX = "cntr_"
37
+
38
+ # One statement, both values: the suffix alone cannot be checked for the
39
+ # per-account form without knowing what that form would be for this account.
40
+ _SUFFIX_SQL = (
41
+ "SELECT SYSTEM$GET_SNOWFLAKE_APP_HOST_SUFFIX(), "
42
+ "LOWER(CURRENT_ORGANIZATION_NAME() || '-' || CURRENT_ACCOUNT_NAME())"
43
+ )
44
+
45
+ # Keyed by base_url, which identifies the account. The suffix is an account
46
+ # property that does not change for the life of a process.
47
+ _SUFFIX_CACHE: dict[str, str] = {}
48
+
49
+
50
+ def validate_preview_port(port: int) -> int:
51
+ """Return ``port`` if the proxy could parse it out of a hostname.
52
+
53
+ The bounds are sandbox-api's, not TCP's in general: ``parseAppKey`` reads the
54
+ port with ParseUint and rejects 0 and a leading zero, so a port this refuses
55
+ would come back as an opaque 400 from the edge rather than anything the caller
56
+ could act on.
57
+ """
58
+ if isinstance(port, bool) or not isinstance(port, int):
59
+ raise SandboxValidationError(f"port must be an int, got {type(port).__name__}")
60
+ if port < 1 or port > 65535:
61
+ raise SandboxValidationError(f"port must be in 1..65535, got {port}")
62
+ return port
63
+
64
+
65
+ def preview_url(sandbox_id: str, port: int, suffix: str) -> str:
66
+ """Build the preview URL. Pure; the suffix is already resolved."""
67
+ handle = sandbox_id.removeprefix(_CONTAINER_ID_PREFIX)
68
+ app_key = f"{handle}-{port}"
69
+ if len(app_key) > _MAX_APP_KEY:
70
+ raise SandboxValidationError(
71
+ f"sandbox id and port do not fit in a DNS label: {app_key!r} "
72
+ f"is {len(app_key)} chars, limit {_MAX_APP_KEY}"
73
+ )
74
+ return f"https://{app_key}-sandbox.{suffix}"
75
+
76
+
77
+ def app_host_suffix(transport: Any = None, connection: Any = None) -> str:
78
+ """Return the account's ``*.snowflake.app`` host suffix, cached per account.
79
+
80
+ Blocking, and deliberately so: the connector cursor is blocking, so this is a
81
+ sync function that each client adapts -- the async client hands it to
82
+ ``asyncio.to_thread`` so it does not stall the loop, the sync client calls it
83
+ directly. Same split ``list_mcp_servers`` uses for its SQL.
84
+ """
85
+ cache_key = _cache_key(transport, connection)
86
+ cached = _SUFFIX_CACHE.get(cache_key) if cache_key else None
87
+ if cached:
88
+ return cached
89
+
90
+ own_connection = connection is None
91
+ if connection is None:
92
+ from snowflake.sandbox._stage import _connect_for_stage
93
+
94
+ try:
95
+ connection, own_connection = _connect_for_stage(transport)
96
+ except Exception as exc:
97
+ raise SandboxError(
98
+ "preview needs a Snowflake connection to resolve this account's "
99
+ f"app host suffix, and none could be opened: {exc}"
100
+ ) from exc
101
+
102
+ try:
103
+ cur = connection.cursor()
104
+ try:
105
+ cur.execute(_SUFFIX_SQL)
106
+ row = cur.fetchone()
107
+ finally:
108
+ cur.close()
109
+ except SandboxError:
110
+ raise
111
+ except Exception as exc:
112
+ # The sysfunc raises a SQL-compilation error naming itself when the
113
+ # account does not have it; that message is the useful one, so pass it on.
114
+ raise SandboxError(f"could not resolve this account's app host suffix: {exc}") from exc
115
+ finally:
116
+ if own_connection:
117
+ try:
118
+ connection.close()
119
+ except Exception: # noqa: BLE001 - closing our own connection is best-effort
120
+ pass
121
+
122
+ suffix = _require_per_account_suffix(row)
123
+ if cache_key:
124
+ _SUFFIX_CACHE[cache_key] = suffix
125
+ return suffix
126
+
127
+
128
+ def _require_per_account_suffix(row: Any) -> str:
129
+ """Return the suffix, refusing the deployment-level form.
130
+
131
+ An account without per-account app URLs (ENABLE_PER_ACCOUNT_APP_SERVICE_URL,
132
+ itself gated on a per-account certificate) gets ``<deployment>.snowflake.app``
133
+ with no ``<org>-<account>`` label. The sandbox edge route only matches the
134
+ per-account form, and no certificate covers the other one -- so a URL built
135
+ from it fails in the TLS handshake, with nothing naming the cause. Refuse here
136
+ instead.
137
+ """
138
+ if not row or not row[0]:
139
+ raise SandboxError("this account reported no app host suffix")
140
+ suffix = str(row[0]).strip().lower()
141
+ # `_` -> `-`: an account name may legitimately contain an underscore, which is
142
+ # not a legal DNS label character, so the host form carries a hyphen instead
143
+ # (the connector does the same mapping for the URL it dials -- see the
144
+ # ACCOUNT_REGION note in config.py). Comparing the raw name would make this
145
+ # guard reject every such account.
146
+ org_acct = str(row[1]).strip().lower().replace("_", "-") if len(row) > 1 and row[1] else ""
147
+ if org_acct and not suffix.startswith(f"{org_acct}."):
148
+ raise SandboxError(
149
+ "preview needs per-account app URLs, which this account does not have: "
150
+ f"its app host suffix is {suffix!r}, not {org_acct!r} followed by the "
151
+ "region. Enable ENABLE_PER_ACCOUNT_APP_SERVICE_URL for the account."
152
+ )
153
+ return suffix
154
+
155
+
156
+ def _cache_key(transport: Any, connection: Any) -> str:
157
+ """Identify the account. Empty means do not cache."""
158
+ if connection is not None:
159
+ return ""
160
+ config = getattr(transport, "config", None)
161
+ try:
162
+ return str(config.base_url) if config is not None else ""
163
+ except Exception: # noqa: BLE001 - an unresolvable config just means no caching
164
+ return ""
@@ -34,7 +34,7 @@ __all__ = [
34
34
  ]
35
35
 
36
36
 
37
- def _session_runner_shim(module: str, name: str) -> str:
37
+ def _session_runner_shim(module: str, name: str, mod_basename: str = "") -> str:
38
38
  """Generate the daemon shim for a ``@app.session`` class.
39
39
 
40
40
  The shim runs as the container's main process (the manifest entry is
@@ -47,7 +47,9 @@ def _session_runner_shim(module: str, name: str) -> str:
47
47
  Behaviour (mirrors ``session_loop`` but with the user's class as warm state):
48
48
 
49
49
  1. Insert the runner's own dir on ``sys.path`` and import the user's class
50
- by ``module:name`` qualname.
50
+ by ``module:name`` qualname — falling back to loading *mod_basename*
51
+ from the bundle root when the package path is not preserved (same
52
+ contract as `_function_runner_source`).
51
53
  2. Instantiate it once — **the instance IS the warm state across turns**.
52
54
  3. Run its cold-start hook once: the method flagged by ``@enter``
53
55
  (``_ENTER_FLAG``), else a method named ``enter`` or ``setup`` if present.
@@ -67,6 +69,7 @@ def _session_runner_shim(module: str, name: str) -> str:
67
69
  return textwrap.dedent(f"""\
68
70
  import base64
69
71
  import importlib
72
+ import importlib.util
70
73
  import json
71
74
  import os
72
75
  import signal
@@ -84,9 +87,34 @@ def _session_runner_shim(module: str, name: str) -> str:
84
87
  # reply. Empty when the deploying SDK predates the nonce.
85
88
  _REPLY_NONCE = os.environ.pop("SANDBOX_SESSION_NONCE", "")
86
89
 
87
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
90
+ _here = os.path.dirname(os.path.abspath(__file__))
91
+ sys.path.insert(0, _here)
92
+
93
+ _MODULE = {module!r}
94
+ _MODFILE = {mod_basename!r}
88
95
 
89
- _mod = importlib.import_module({module!r})
96
+ def _load_user_module():
97
+ # Prefer import-by-name (packages / sibling imports). Fall back to
98
+ # loading the bundled source file — the session bundle root is the
99
+ # class's source directory, so a dotted package name like
100
+ # ``tests.sandbox.fixtures.live_workload`` is NOT on sys.path even
101
+ # though ``live_workload.py`` is next to this shim.
102
+ if _MODULE and _MODULE != "__main__":
103
+ try:
104
+ return importlib.import_module(_MODULE)
105
+ except Exception:
106
+ pass
107
+ _path = os.path.join(_here, _MODFILE)
108
+ if _MODFILE and os.path.isfile(_path):
109
+ _spec = importlib.util.spec_from_file_location(
110
+ "__sandbox_user_module__", _path
111
+ )
112
+ _m = importlib.util.module_from_spec(_spec)
113
+ _spec.loader.exec_module(_m)
114
+ return _m
115
+ return importlib.import_module(_MODULE)
116
+
117
+ _mod = _load_user_module()
90
118
  _cls = getattr(_mod, {name!r})
91
119
  _instance = _cls()
92
120