snowflake-sandbox-python 0.2.2a4__py3-none-any.whl → 0.2.2a6__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/_egress_flags.py +34 -25
- snowflake/cli_sandbox/_git_repo_flags.py +58 -0
- snowflake/cli_sandbox/_lifecycle_command.py +108 -0
- snowflake/cli_sandbox/_remote_command.py +168 -0
- snowflake/cli_sandbox/_run_command.py +64 -3
- snowflake/cli_sandbox/_shell_command.py +116 -38
- snowflake/cli_sandbox/_upload_plan.py +1 -1
- snowflake/cli_sandbox/commands.py +86 -48
- snowflake/sandbox/__init__.py +13 -0
- snowflake/sandbox/__main__.py +4 -5
- snowflake/sandbox/_args.py +9 -65
- snowflake/sandbox/_bundle.py +2 -2
- snowflake/sandbox/_code_delivery.py +88 -0
- snowflake/sandbox/_deploy_spec.py +13 -5
- snowflake/sandbox/_diagnostics.py +5 -2
- snowflake/sandbox/_env.py +1 -1
- snowflake/sandbox/_fs_ops.py +3 -4
- snowflake/sandbox/_preview.py +173 -0
- snowflake/sandbox/_proto/__init__.py +0 -0
- snowflake/sandbox/_proto/sandbox_pb2.py +175 -0
- snowflake/sandbox/_proto/sandbox_pb2.pyi +704 -0
- snowflake/sandbox/_retry.py +15 -8
- snowflake/sandbox/_runtime/_shims.py +32 -4
- snowflake/sandbox/_sandbox_state.py +83 -18
- snowflake/sandbox/_session.py +143 -0
- snowflake/sandbox/_sse.py +2 -2
- snowflake/sandbox/_ssh.py +232 -0
- snowflake/sandbox/_sync_transport.py +23 -18
- snowflake/sandbox/_transport.py +50 -54
- snowflake/sandbox/_tunnel.py +185 -0
- snowflake/sandbox/_upload_plan.py +1 -1
- snowflake/sandbox/_version.py +1 -1
- snowflake/sandbox/_wire.py +64 -6
- snowflake/sandbox/app.py +2 -2
- snowflake/sandbox/client.py +558 -352
- snowflake/sandbox/connect.py +14 -14
- snowflake/sandbox/deploy.py +122 -22
- snowflake/sandbox/egress.py +92 -23
- snowflake/sandbox/endpoint.py +515 -0
- snowflake/sandbox/exceptions.py +54 -5
- snowflake/sandbox/failure.py +16 -17
- snowflake/sandbox/git_repo.py +191 -0
- snowflake/sandbox/image.py +542 -18
- snowflake/sandbox/jobs.py +59 -34
- snowflake/sandbox/log_stream.py +333 -129
- snowflake/sandbox/mcp.py +2 -2
- snowflake/sandbox/secret.py +88 -22
- snowflake/sandbox/session_app.py +18 -3
- snowflake/sandbox/shell.py +2 -3
- snowflake/sandbox/snowflake_connection.py +83 -0
- snowflake/sandbox/sync_client.py +555 -348
- snowflake/sandbox/sync_log_stream.py +79 -111
- snowflake/sandbox/sync_shell.py +2 -3
- snowflake/sandbox/warm_session.py +7 -7
- {snowflake_sandbox_python-0.2.2a4.dist-info → snowflake_sandbox_python-0.2.2a6.dist-info}/METADATA +55 -1
- snowflake_sandbox_python-0.2.2a6.dist-info/RECORD +95 -0
- {snowflake_sandbox_python-0.2.2a4.dist-info → snowflake_sandbox_python-0.2.2a6.dist-info}/WHEEL +1 -1
- snowflake_sandbox_python-0.2.2a4.dist-info/RECORD +0 -81
- {snowflake_sandbox_python-0.2.2a4.dist-info → snowflake_sandbox_python-0.2.2a6.dist-info}/entry_points.txt +0 -0
- {snowflake_sandbox_python-0.2.2a4.dist-info → snowflake_sandbox_python-0.2.2a6.dist-info}/licenses/LICENSE +0 -0
- {snowflake_sandbox_python-0.2.2a4.dist-info → snowflake_sandbox_python-0.2.2a6.dist-info}/top_level.txt +0 -0
|
@@ -10,14 +10,10 @@ Spellings, chosen to match what the CLI already does elsewhere:
|
|
|
10
10
|
* ``--eai NAME`` — repeatable, like ``--env``. Names an External Access Integration
|
|
11
11
|
whose network rules the platform resolves; that is the only way to reach a host
|
|
12
12
|
outside the built-in baseline.
|
|
13
|
-
* ``--secret ENV_VAR=DB.SCHEMA.NAME@host[,host]`` — repeatable. The env var comes
|
|
13
|
+
* ``--secret ENV_VAR=DB.SCHEMA.NAME[@host[,host]]`` — repeatable. The env var comes
|
|
14
14
|
first because that is the order ``--env KEY=VAL`` established, and it is the half
|
|
15
|
-
you read inside the sandbox.
|
|
16
|
-
|
|
17
|
-
is **always required**: the SDK refuses an unscoped credential rather than let it
|
|
18
|
-
egress anywhere. (Its error text says ``host=`` becomes optional once an EAI supplies
|
|
19
|
-
the hosts — that is not implemented; an EAI does not relax the requirement, verified
|
|
20
|
-
against `compile_egress`.)
|
|
15
|
+
you read inside the sandbox. The ``@host`` scopes where the credential may be sent
|
|
16
|
+
and is required unless an ``--eai`` supplies the host grant.
|
|
21
17
|
* ``--no-default-egress`` — the closed level: Snowflake and its stages only, no
|
|
22
18
|
baseline package-manager hosts. An ``--eai`` still grants on top of it, which is
|
|
23
19
|
why this is not called ``--no-egress``.
|
|
@@ -27,7 +23,7 @@ from __future__ import annotations
|
|
|
27
23
|
|
|
28
24
|
import typer
|
|
29
25
|
|
|
30
|
-
from snowflake.sandbox.egress import Egress, compile_egress
|
|
26
|
+
from snowflake.sandbox.egress import Egress, compile_egress, compile_secrets
|
|
31
27
|
from snowflake.sandbox.secret import Secret
|
|
32
28
|
|
|
33
29
|
__all__ = ["EAI_HELP", "NO_DEFAULT_EGRESS_HELP", "SECRET_HELP", "build_egress"]
|
|
@@ -38,10 +34,17 @@ EAI_HELP = (
|
|
|
38
34
|
"readable or the platform refuses the create."
|
|
39
35
|
)
|
|
40
36
|
|
|
37
|
+
OUTBOUND_WIF_HOST_HELP = (
|
|
38
|
+
"Host that gets a Snowflake workload identity token as Authorization: Bearer, so it "
|
|
39
|
+
"can verify the calling account without a stored secret; repeatable. An exact host "
|
|
40
|
+
"or a leading-dot suffix (.example.com). It does not open egress: the host must also "
|
|
41
|
+
"be reachable, e.g. through --eai."
|
|
42
|
+
)
|
|
43
|
+
|
|
41
44
|
SECRET_HELP = (
|
|
42
|
-
"Snowflake SECRET to inject, as ENV_VAR=DB.SCHEMA.NAME@host[,host]; repeatable. "
|
|
43
|
-
"The @host scopes where the credential may be sent and is required
|
|
44
|
-
"
|
|
45
|
+
"Snowflake SECRET to inject, as ENV_VAR=DB.SCHEMA.NAME[@host[,host]]; repeatable. "
|
|
46
|
+
"The @host scopes where the credential may be sent and is required unless an "
|
|
47
|
+
"--eai supplies the host grant. ENV_VAR= is required."
|
|
45
48
|
)
|
|
46
49
|
|
|
47
50
|
NO_DEFAULT_EGRESS_HELP = (
|
|
@@ -64,6 +67,8 @@ def _parse_secret(raw: str) -> Secret:
|
|
|
64
67
|
env_var, spec = env_var.strip(), spec.strip()
|
|
65
68
|
if not env_var:
|
|
66
69
|
raise typer.BadParameter(f"empty env var in {raw!r} (expected ENV_VAR=DB.SCHEMA.NAME)")
|
|
70
|
+
else:
|
|
71
|
+
raise typer.BadParameter(f"{raw!r}: expected ENV_VAR=DB.SCHEMA.NAME[@host]")
|
|
67
72
|
|
|
68
73
|
hosts: tuple[str, ...] = ()
|
|
69
74
|
if "@" in spec:
|
|
@@ -89,38 +94,42 @@ def build_egress(
|
|
|
89
94
|
eai: list[str] | None,
|
|
90
95
|
secret: list[str] | None,
|
|
91
96
|
no_default_egress: bool,
|
|
97
|
+
outbound_wif_host: list[str] | None = None,
|
|
92
98
|
) -> tuple[Egress | None, tuple[Secret, ...]]:
|
|
93
99
|
"""`(egress, secrets)` for these flags — both empty when none were given.
|
|
94
100
|
|
|
95
101
|
Two values because that is the shape `Sandbox.create()` takes: secrets are authored
|
|
96
|
-
alongside egress
|
|
97
|
-
wire, where `compile_egress` puts them).
|
|
102
|
+
alongside egress and sent as their own top-level create field.
|
|
98
103
|
|
|
99
104
|
`None` rather than an empty `Egress` when nothing was passed, so the create body
|
|
100
105
|
stays byte-identical to today's for every existing caller.
|
|
101
106
|
"""
|
|
102
107
|
integrations = tuple(dict.fromkeys(name.strip() for name in (eai or []) if name.strip()))
|
|
103
108
|
secrets = tuple(_parse_secret(raw) for raw in (secret or []))
|
|
104
|
-
|
|
109
|
+
wif_hosts = tuple(dict.fromkeys(h.strip() for h in (outbound_wif_host or []) if h.strip()))
|
|
110
|
+
if not integrations and not secrets and not no_default_egress and not wif_hosts:
|
|
105
111
|
return None, ()
|
|
106
112
|
|
|
107
|
-
egress = Egress(
|
|
108
|
-
external_access_integrations=integrations,
|
|
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 {}),
|
|
115
|
-
)
|
|
116
113
|
# Validate now, not at create time. `compile_egress` is pure and is what the client
|
|
117
114
|
# runs anyway -- but it runs *after* connecting, so a mistyped host pair otherwise
|
|
118
115
|
# costs a Snowflake round trip before saying so. These are usage errors; they should
|
|
119
|
-
# cost nothing.
|
|
116
|
+
# cost nothing. Egress() is inside the try too: it rejects a bad --outbound-wif-host.
|
|
120
117
|
from snowflake.sandbox.exceptions import SandboxError
|
|
121
118
|
|
|
122
119
|
try:
|
|
123
|
-
|
|
120
|
+
egress = Egress(
|
|
121
|
+
external_access_integrations=integrations,
|
|
122
|
+
# Uses the current parameter name. The CLI FLAG stays `--no-default-egress`: it
|
|
123
|
+
# is released, renaming it would break users' scripts, and that is a separate
|
|
124
|
+
# decision from the library parameter's name. So the flag spelling and the
|
|
125
|
+
# parameter spelling differ here deliberately — the alternative is the CLI
|
|
126
|
+
# depending on a deprecated parameter, which is worse. None is unset, which
|
|
127
|
+
# sends nothing for the level.
|
|
128
|
+
allow_public_package_registries=False if no_default_egress else None,
|
|
129
|
+
outbound_wif_hosts=wif_hosts,
|
|
130
|
+
)
|
|
131
|
+
compile_egress(egress, ())
|
|
132
|
+
compile_secrets(secrets, has_eai=bool(integrations))
|
|
124
133
|
except SandboxError as exc:
|
|
125
134
|
raise typer.BadParameter(str(exc)) from exc
|
|
126
135
|
return egress, secrets
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""`--git-repo` / `--git-integration`: mount Git repositories into a run's sandbox.
|
|
2
|
+
|
|
3
|
+
* ``--git-repo URL[=MOUNT_PATH]`` — repeatable. The first ``=`` starts the mount path.
|
|
4
|
+
* ``--git-integration NAME`` — the ``EXTERNAL_APPLICATION`` integration every
|
|
5
|
+
``--git-repo`` is cloned through, one per run.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from snowflake.sandbox.git_repo import GitRepo
|
|
16
|
+
|
|
17
|
+
__all__ = ["GIT_INTEGRATION_HELP", "GIT_REPO_HELP", "build_git_repos"]
|
|
18
|
+
|
|
19
|
+
GIT_REPO_HELP = (
|
|
20
|
+
"Git repository to mount, as an https URL with an optional =MOUNT_PATH "
|
|
21
|
+
"(default /mnt/repos/<repo>); repeatable. Needs --git-integration. The default "
|
|
22
|
+
"branch is mounted writable, and git push from the sandbox has no credential."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
GIT_INTEGRATION_HELP = (
|
|
26
|
+
"Snowflake EXTERNAL_APPLICATION integration that every --git-repo is cloned "
|
|
27
|
+
"through, with your own authorization of it."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_git_repos(raw: list[str] | None, integration: str | None) -> tuple[GitRepo, ...]:
|
|
32
|
+
"""The `GitRepo`s for these flags, or a `typer.BadParameter`."""
|
|
33
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
34
|
+
from snowflake.sandbox.git_repo import GitRepo, validate_git_repos
|
|
35
|
+
|
|
36
|
+
if not raw:
|
|
37
|
+
if integration:
|
|
38
|
+
raise typer.BadParameter("--git-integration needs at least one --git-repo.")
|
|
39
|
+
return ()
|
|
40
|
+
if not integration:
|
|
41
|
+
raise typer.BadParameter(
|
|
42
|
+
"--git-repo needs --git-integration NAME, the EXTERNAL_APPLICATION "
|
|
43
|
+
"integration the repository is cloned through."
|
|
44
|
+
)
|
|
45
|
+
repos: list[GitRepo] = []
|
|
46
|
+
for spec in raw:
|
|
47
|
+
url, _, mount_path = spec.strip().partition("=")
|
|
48
|
+
try:
|
|
49
|
+
repos.append(
|
|
50
|
+
GitRepo(url.strip(), integration=integration, mount_path=mount_path.strip())
|
|
51
|
+
)
|
|
52
|
+
except SandboxError as exc:
|
|
53
|
+
raise typer.BadParameter(f"--git-repo {spec!r}: {exc}") from exc
|
|
54
|
+
try:
|
|
55
|
+
validate_git_repos(repos)
|
|
56
|
+
except SandboxError as exc:
|
|
57
|
+
raise typer.BadParameter(f"--git-repo: {exc}") from exc
|
|
58
|
+
return tuple(repos)
|
|
@@ -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"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""CLI entry points for SSH, local TCP forwarding and the desktop page."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from contextlib import redirect_stdout
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from snowflake.cli_sandbox._common import (
|
|
14
|
+
_CONN_HELP,
|
|
15
|
+
_USE_SNOW_CONN_HELP,
|
|
16
|
+
_apply_connection,
|
|
17
|
+
_resolve_sandbox_id,
|
|
18
|
+
)
|
|
19
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from snowflake.sandbox.config import Config
|
|
23
|
+
|
|
24
|
+
_SSH_HELP = "Connect with OpenSSH over an authenticated sandbox tunnel. Requires OpenSSH on PATH.\n\nExample:\n snow sandbox ssh my-sandbox\n snow sandbox ssh my-sandbox --cmd 'uname -a'"
|
|
25
|
+
_PORT_FORWARD_HELP = "Forward a loopback TCP port to a port inside an existing sandbox.\n\nExample:\n snow sandbox port-forward my-sandbox 8080 --local-port 18080"
|
|
26
|
+
_DESKTOP_HELP = "Open the remote desktop page for an existing sandbox.\n\nExample:\n snow sandbox desktop my-sandbox\n snow sandbox desktop my-sandbox --no-open"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _remote_config(use_snow_connection: bool, connection: str | None) -> Config:
|
|
30
|
+
from snowflake.cli_sandbox._adapter import sdk_connection
|
|
31
|
+
from snowflake.sandbox.config import resolve_config
|
|
32
|
+
|
|
33
|
+
_apply_connection(use_snow_connection, connection=connection)
|
|
34
|
+
return resolve_config(connection=sdk_connection())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ssh_command(
|
|
38
|
+
sandbox_id: str = typer.Argument(..., help="Existing sandbox name or app id."),
|
|
39
|
+
user: str = typer.Option("root", "--user", help="SSH login user inside the sandbox."),
|
|
40
|
+
cmd: str | None = typer.Option(
|
|
41
|
+
None, "--cmd", help="Remote command; omit for an interactive SSH session."
|
|
42
|
+
),
|
|
43
|
+
use_snow_connection: bool = typer.Option(
|
|
44
|
+
True, "--use-snow-connection/--no-snow-connection", help=_USE_SNOW_CONN_HELP
|
|
45
|
+
),
|
|
46
|
+
connection: str | None = typer.Option(None, "--connection", "-c", help=_CONN_HELP),
|
|
47
|
+
proxy: bool = typer.Option(False, "--proxy", hidden=True),
|
|
48
|
+
identity: Path | None = typer.Option(None, "--identity", hidden=True),
|
|
49
|
+
known_hosts: Path | None = typer.Option(None, "--known-hosts", hidden=True),
|
|
50
|
+
**options: Any,
|
|
51
|
+
) -> None:
|
|
52
|
+
from snowflake.sandbox._preview import preview_url
|
|
53
|
+
from snowflake.sandbox._ssh import run_ssh, ssh_proxy
|
|
54
|
+
from snowflake.sandbox._tunnel import resolve_remote_url
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
if sandbox_id.startswith("cntr_"):
|
|
58
|
+
preview_url(sandbox_id, 8079, "org-account.example")
|
|
59
|
+
if proxy and (identity is None or known_hosts is None):
|
|
60
|
+
raise typer.BadParameter("--proxy requires --identity and --known-hosts")
|
|
61
|
+
if proxy or not sandbox_id.startswith("cntr_"):
|
|
62
|
+
# Connector/browser-login notices must not enter the SSH stream.
|
|
63
|
+
with redirect_stdout(sys.stderr):
|
|
64
|
+
config = _remote_config(use_snow_connection, connection)
|
|
65
|
+
sandbox_id = _resolve_sandbox_id(sandbox_id)
|
|
66
|
+
# Validate before key generation or spawning a process.
|
|
67
|
+
preview_url(sandbox_id, 8079, "org-account.example")
|
|
68
|
+
if proxy:
|
|
69
|
+
if identity is None or known_hosts is None:
|
|
70
|
+
raise typer.BadParameter("--proxy requires --identity and --known-hosts")
|
|
71
|
+
|
|
72
|
+
async def pump() -> None:
|
|
73
|
+
with redirect_stdout(sys.stderr):
|
|
74
|
+
url = await resolve_remote_url(sandbox_id, config, service="ssh")
|
|
75
|
+
await ssh_proxy(
|
|
76
|
+
url, config, identity=identity, known_hosts=known_hosts, alias=sandbox_id
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
asyncio.run(pump())
|
|
80
|
+
code = 0
|
|
81
|
+
else:
|
|
82
|
+
code = run_ssh(
|
|
83
|
+
sandbox_id,
|
|
84
|
+
user=user,
|
|
85
|
+
command=cmd,
|
|
86
|
+
connection=connection,
|
|
87
|
+
use_snow_connection=use_snow_connection,
|
|
88
|
+
)
|
|
89
|
+
except KeyboardInterrupt:
|
|
90
|
+
raise typer.Exit(130) from None
|
|
91
|
+
except (SandboxError, OSError) as exc:
|
|
92
|
+
typer.echo(f"error: {exc}", err=True)
|
|
93
|
+
raise typer.Exit(1) from exc
|
|
94
|
+
raise typer.Exit(code)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _port_forward_command(
|
|
98
|
+
sandbox_id: str = typer.Argument(..., help="Existing sandbox name or app id."),
|
|
99
|
+
port: int = typer.Argument(..., help="TCP port inside the sandbox."),
|
|
100
|
+
local_port: int | None = typer.Option(
|
|
101
|
+
None, "--local-port", help="Loopback port to listen on (default: remote port)."
|
|
102
|
+
),
|
|
103
|
+
use_snow_connection: bool = typer.Option(
|
|
104
|
+
True, "--use-snow-connection/--no-snow-connection", help=_USE_SNOW_CONN_HELP
|
|
105
|
+
),
|
|
106
|
+
connection: str | None = typer.Option(None, "--connection", "-c", help=_CONN_HELP),
|
|
107
|
+
**options: Any,
|
|
108
|
+
) -> None:
|
|
109
|
+
from snowflake.sandbox._preview import validate_preview_port
|
|
110
|
+
from snowflake.sandbox._tunnel import forward_port, remote_url, resolve_remote_url
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
remote_url(
|
|
114
|
+
sandbox_id if sandbox_id.startswith("cntr_") else "cntr_validation",
|
|
115
|
+
"org-account.example",
|
|
116
|
+
port=port,
|
|
117
|
+
)
|
|
118
|
+
listen_port = validate_preview_port(port if local_port is None else local_port)
|
|
119
|
+
config = _remote_config(use_snow_connection, connection)
|
|
120
|
+
sandbox_id = _resolve_sandbox_id(sandbox_id)
|
|
121
|
+
|
|
122
|
+
async def forward() -> None:
|
|
123
|
+
url = await resolve_remote_url(sandbox_id, config, port=port)
|
|
124
|
+
await forward_port(
|
|
125
|
+
url,
|
|
126
|
+
config,
|
|
127
|
+
port=port,
|
|
128
|
+
local_port=listen_port,
|
|
129
|
+
ready=lambda bound: typer.echo(
|
|
130
|
+
f"Forwarding 127.0.0.1:{bound} to {sandbox_id}:{port}", err=True
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
asyncio.run(forward())
|
|
135
|
+
except KeyboardInterrupt:
|
|
136
|
+
raise typer.Exit(130) from None
|
|
137
|
+
except (SandboxError, OSError) as exc:
|
|
138
|
+
typer.echo(f"error: {exc}", err=True)
|
|
139
|
+
raise typer.Exit(1) from exc
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _desktop_command(
|
|
143
|
+
sandbox_id: str = typer.Argument(..., help="Existing sandbox name or app id."),
|
|
144
|
+
no_open: bool = typer.Option(
|
|
145
|
+
False, "--no-open", help="Print the desktop URL without opening a browser."
|
|
146
|
+
),
|
|
147
|
+
use_snow_connection: bool = typer.Option(
|
|
148
|
+
True, "--use-snow-connection/--no-snow-connection", help=_USE_SNOW_CONN_HELP
|
|
149
|
+
),
|
|
150
|
+
connection: str | None = typer.Option(None, "--connection", "-c", help=_CONN_HELP),
|
|
151
|
+
**options: Any,
|
|
152
|
+
) -> None:
|
|
153
|
+
import webbrowser
|
|
154
|
+
|
|
155
|
+
from snowflake.sandbox._tunnel import remote_url, resolve_remote_url
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
if sandbox_id.startswith("cntr_"):
|
|
159
|
+
remote_url(sandbox_id, "org-account.example", service="desktop")
|
|
160
|
+
config = _remote_config(use_snow_connection, connection)
|
|
161
|
+
sandbox_id = _resolve_sandbox_id(sandbox_id)
|
|
162
|
+
url = asyncio.run(resolve_remote_url(sandbox_id, config, service="desktop"))
|
|
163
|
+
typer.echo(url)
|
|
164
|
+
if not no_open:
|
|
165
|
+
webbrowser.open(url)
|
|
166
|
+
except (SandboxError, OSError) as exc:
|
|
167
|
+
typer.echo(f"error: {exc}", err=True)
|
|
168
|
+
raise typer.Exit(1) from exc
|
|
@@ -62,9 +62,15 @@ from snowflake.cli_sandbox._common import (
|
|
|
62
62
|
from snowflake.cli_sandbox._egress_flags import (
|
|
63
63
|
EAI_HELP,
|
|
64
64
|
NO_DEFAULT_EGRESS_HELP,
|
|
65
|
+
OUTBOUND_WIF_HOST_HELP,
|
|
65
66
|
SECRET_HELP,
|
|
66
67
|
build_egress,
|
|
67
68
|
)
|
|
69
|
+
from snowflake.cli_sandbox._git_repo_flags import (
|
|
70
|
+
GIT_INTEGRATION_HELP,
|
|
71
|
+
GIT_REPO_HELP,
|
|
72
|
+
build_git_repos,
|
|
73
|
+
)
|
|
68
74
|
from snowflake.cli_sandbox._shell_command import (
|
|
69
75
|
_emit_status,
|
|
70
76
|
_exec_streaming,
|
|
@@ -323,6 +329,10 @@ def _print_inferred_plan(plan: _RunPlan) -> None:
|
|
|
323
329
|
if plan.secrets:
|
|
324
330
|
bits.append(f"secrets={len(plan.secrets)}")
|
|
325
331
|
typer.echo(f"egress: {' '.join(b for b in bits if b)}")
|
|
332
|
+
for repo in plan.git_repos:
|
|
333
|
+
typer.echo(
|
|
334
|
+
f"git repo: {repo.url} -> {repo.resolved_mount_path} (integration {repo.integration})" # type: ignore[attr-defined]
|
|
335
|
+
)
|
|
326
336
|
if plan.upload is not None:
|
|
327
337
|
typer.echo(
|
|
328
338
|
f"upload: {plan.upload.file_count} file(s), "
|
|
@@ -359,6 +369,7 @@ class _RunPlan:
|
|
|
359
369
|
env: dict[str, str]
|
|
360
370
|
egress: object | None # Egress | None -- typed loosely to keep the SDK import lazy
|
|
361
371
|
secrets: tuple[object, ...]
|
|
372
|
+
git_repos: tuple[object, ...] # GitRepo, typed loosely like egress
|
|
362
373
|
quiet: bool
|
|
363
374
|
|
|
364
375
|
|
|
@@ -373,6 +384,8 @@ def _reject_app_incompatible_flags(
|
|
|
373
384
|
eai: list[str] | None,
|
|
374
385
|
secret: list[str] | None,
|
|
375
386
|
nde: bool,
|
|
387
|
+
outbound_wif_host: list[str] | None = None,
|
|
388
|
+
git_repo: list[str] | None = None,
|
|
376
389
|
) -> None:
|
|
377
390
|
"""Everything an App module declares for itself, and so cannot be told from a flag.
|
|
378
391
|
|
|
@@ -399,12 +412,19 @@ def _reject_app_incompatible_flags(
|
|
|
399
412
|
raise typer.BadParameter(
|
|
400
413
|
f"{flag} applies only to local code; an App declares its own bundle."
|
|
401
414
|
)
|
|
402
|
-
for flag, value in (
|
|
415
|
+
for flag, value in (
|
|
416
|
+
("--eai", eai),
|
|
417
|
+
("--secret", secret),
|
|
418
|
+
("--no-default-egress", nde),
|
|
419
|
+
("--outbound-wif-host", outbound_wif_host),
|
|
420
|
+
):
|
|
403
421
|
if value:
|
|
404
422
|
raise typer.BadParameter(
|
|
405
423
|
f"{flag} does not apply to an App module; an App declares its own egress "
|
|
406
424
|
f"and secrets in code (see @app.function(egress=..., secrets=...))."
|
|
407
425
|
)
|
|
426
|
+
if git_repo:
|
|
427
|
+
raise typer.BadParameter("--git-repo applies only to an image or local-code run.")
|
|
408
428
|
|
|
409
429
|
|
|
410
430
|
def _reject_inapplicable_flags(
|
|
@@ -421,6 +441,8 @@ def _reject_inapplicable_flags(
|
|
|
421
441
|
eai: list[str] | None = None,
|
|
422
442
|
secret: list[str] | None = None,
|
|
423
443
|
nde: bool = False,
|
|
444
|
+
outbound_wif_host: list[str] | None = None,
|
|
445
|
+
git_repo: list[str] | None = None,
|
|
424
446
|
) -> None:
|
|
425
447
|
"""Reject flag combinations that cannot take effect, loudly rather than silently.
|
|
426
448
|
|
|
@@ -438,6 +460,8 @@ def _reject_inapplicable_flags(
|
|
|
438
460
|
eai=eai,
|
|
439
461
|
secret=secret,
|
|
440
462
|
nde=nde,
|
|
463
|
+
outbound_wif_host=outbound_wif_host,
|
|
464
|
+
git_repo=git_repo,
|
|
441
465
|
)
|
|
442
466
|
elif function is not None:
|
|
443
467
|
raise typer.BadParameter("--function applies only to an App module (--app).")
|
|
@@ -540,6 +564,9 @@ def _plan_from_args(
|
|
|
540
564
|
eai: list[str] | None = None,
|
|
541
565
|
secret: list[str] | None = None,
|
|
542
566
|
no_default_egress: bool = False,
|
|
567
|
+
outbound_wif_host: list[str] | None = None,
|
|
568
|
+
git_repo: list[str] | None = None,
|
|
569
|
+
git_integration: str | None = None,
|
|
543
570
|
quiet: bool = False,
|
|
544
571
|
) -> _RunPlan:
|
|
545
572
|
"""Validate the flags and settle a `_RunPlan` (raising `typer.BadParameter`).
|
|
@@ -570,7 +597,10 @@ def _plan_from_args(
|
|
|
570
597
|
eai=eai,
|
|
571
598
|
secret=secret,
|
|
572
599
|
nde=no_default_egress,
|
|
600
|
+
outbound_wif_host=outbound_wif_host,
|
|
601
|
+
git_repo=git_repo,
|
|
573
602
|
)
|
|
603
|
+
git_repos = build_git_repos(git_repo, git_integration)
|
|
574
604
|
|
|
575
605
|
env_overrides: dict[str, str] = {}
|
|
576
606
|
for raw in envs or []:
|
|
@@ -581,7 +611,10 @@ def _plan_from_args(
|
|
|
581
611
|
# silently overridden rather than merged -- rejected above in
|
|
582
612
|
# `_reject_inapplicable_flags`, which is why this can run unconditionally.
|
|
583
613
|
egress_cfg, secret_objs = build_egress(
|
|
584
|
-
eai=eai,
|
|
614
|
+
eai=eai,
|
|
615
|
+
secret=secret,
|
|
616
|
+
no_default_egress=no_default_egress,
|
|
617
|
+
outbound_wif_host=outbound_wif_host,
|
|
585
618
|
)
|
|
586
619
|
|
|
587
620
|
upload: UploadPlan | None = None
|
|
@@ -661,6 +694,7 @@ def _plan_from_args(
|
|
|
661
694
|
env=env_overrides,
|
|
662
695
|
egress=egress_cfg,
|
|
663
696
|
secrets=secret_objs,
|
|
697
|
+
git_repos=git_repos,
|
|
664
698
|
quiet=quiet,
|
|
665
699
|
)
|
|
666
700
|
|
|
@@ -807,6 +841,7 @@ async def _run_job(plan: _RunPlan) -> int:
|
|
|
807
841
|
env=plan.env or None,
|
|
808
842
|
egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
|
|
809
843
|
secrets=plan.secrets, # type: ignore[arg-type]
|
|
844
|
+
git_repos=plan.git_repos, # type: ignore[arg-type]
|
|
810
845
|
)
|
|
811
846
|
|
|
812
847
|
|
|
@@ -830,6 +865,7 @@ async def _run_detached_image(plan: _RunPlan) -> None:
|
|
|
830
865
|
env=plan.env or None,
|
|
831
866
|
egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
|
|
832
867
|
secrets=plan.secrets, # type: ignore[arg-type]
|
|
868
|
+
git_repos=plan.git_repos, # type: ignore[arg-type]
|
|
833
869
|
name=plan.name,
|
|
834
870
|
command=command,
|
|
835
871
|
idle_suspend=plan.idle_suspend,
|
|
@@ -867,6 +903,7 @@ async def _run_detached_local(plan: _RunPlan) -> None:
|
|
|
867
903
|
env=plan.env or None,
|
|
868
904
|
egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
|
|
869
905
|
secrets=plan.secrets, # type: ignore[arg-type]
|
|
906
|
+
git_repos=plan.git_repos, # type: ignore[arg-type]
|
|
870
907
|
name=plan.name,
|
|
871
908
|
idle_suspend=plan.idle_suspend,
|
|
872
909
|
)
|
|
@@ -997,7 +1034,7 @@ def _run_command(
|
|
|
997
1034
|
secret: list[str] | None = typer.Option(
|
|
998
1035
|
None,
|
|
999
1036
|
"--secret",
|
|
1000
|
-
metavar="ENV_VAR=DB.SCHEMA.NAME",
|
|
1037
|
+
metavar="ENV_VAR=DB.SCHEMA.NAME[@host]",
|
|
1001
1038
|
help=SECRET_HELP,
|
|
1002
1039
|
),
|
|
1003
1040
|
no_default_egress: bool = typer.Option(
|
|
@@ -1005,6 +1042,27 @@ def _run_command(
|
|
|
1005
1042
|
"--no-default-egress",
|
|
1006
1043
|
help=NO_DEFAULT_EGRESS_HELP,
|
|
1007
1044
|
),
|
|
1045
|
+
outbound_wif_host: list[str] | None = typer.Option(
|
|
1046
|
+
None,
|
|
1047
|
+
"--outbound-wif-host",
|
|
1048
|
+
metavar="HOST",
|
|
1049
|
+
help=OUTBOUND_WIF_HOST_HELP,
|
|
1050
|
+
hidden=True, # unreleased; see CONTRIBUTING "Hiding an unreleased parameter"
|
|
1051
|
+
),
|
|
1052
|
+
git_repo: list[str] | None = typer.Option(
|
|
1053
|
+
None,
|
|
1054
|
+
"--git-repo",
|
|
1055
|
+
metavar="URL[=MOUNT_PATH]",
|
|
1056
|
+
help=GIT_REPO_HELP,
|
|
1057
|
+
hidden=True, # unreleased; see CONTRIBUTING "Hiding an unreleased parameter"
|
|
1058
|
+
),
|
|
1059
|
+
git_integration: str | None = typer.Option(
|
|
1060
|
+
None,
|
|
1061
|
+
"--git-integration",
|
|
1062
|
+
metavar="NAME",
|
|
1063
|
+
help=GIT_INTEGRATION_HELP,
|
|
1064
|
+
hidden=True,
|
|
1065
|
+
),
|
|
1008
1066
|
allow_large: bool = typer.Option(
|
|
1009
1067
|
False,
|
|
1010
1068
|
"--allow-large",
|
|
@@ -1076,6 +1134,9 @@ def _run_command(
|
|
|
1076
1134
|
eai=eai,
|
|
1077
1135
|
secret=secret,
|
|
1078
1136
|
no_default_egress=no_default_egress,
|
|
1137
|
+
outbound_wif_host=outbound_wif_host,
|
|
1138
|
+
git_repo=git_repo,
|
|
1139
|
+
git_integration=git_integration,
|
|
1079
1140
|
quiet=quiet,
|
|
1080
1141
|
)
|
|
1081
1142
|
_hint_app_module(plan)
|