Skip to content

Commit e1581b0

Browse files
committed
Add basic remote commands
1 parent d974a63 commit e1581b0

File tree

10 files changed

+1444
-57
lines changed

10 files changed

+1444
-57
lines changed

src/snowflake/cli/_plugins/remote/commands.py

Lines changed: 142 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,36 +15,163 @@
1515
from __future__ import annotations
1616

1717
import logging
18+
from typing import List, Optional
1819

20+
import typer
21+
from snowflake.cli._plugins.remote.manager import RemoteManager
1922
from snowflake.cli.api.commands.snow_typer import SnowTyperFactory
23+
from snowflake.cli.api.console import cli_console as cc
24+
from snowflake.cli.api.output.types import (
25+
CommandResult,
26+
QueryResult,
27+
SingleQueryResult,
28+
)
2029

21-
log = logging.getLogger(__name__)
2230
app = SnowTyperFactory(
2331
name="remote",
24-
help="Manages remote development environments.",
32+
help="Manages remote development environments on top of Snowpark Container Service.",
33+
short_help="Manages remote development environments.",
34+
)
35+
36+
log = logging.getLogger(__name__)
37+
38+
# Define argument for remote service names (accepts both customer names and full service names)
39+
RemoteNameArgument = typer.Argument(
40+
help="Remote service name. Can be either a customer name (e.g., 'myproject') or full service name (e.g., 'SNOW_REMOTE_admin_myproject')",
41+
show_default=False,
2542
)
2643

2744

2845
@app.command("start", requires_connection=True)
29-
def start_service(**options) -> None:
46+
def start(
47+
name: Optional[str] = typer.Argument(
48+
None,
49+
help="Service name to resume, or leave empty to create a new service with auto-generated name",
50+
),
51+
compute_pool: Optional[str] = typer.Option(
52+
None,
53+
"--compute-pool",
54+
help="Name of the compute pool to use (required for new service creation)",
55+
show_default=False,
56+
),
57+
eai_name: Optional[List[str]] = typer.Option(
58+
None,
59+
"--eai-name",
60+
help="List of external access integration names to enable network access to external resources",
61+
),
62+
stage: Optional[str] = typer.Option(
63+
None,
64+
"--stage",
65+
help="Internal Snowflake stage to mount (e.g., @my_stage or @my_stage/folder).",
66+
),
67+
image: Optional[str] = typer.Option(
68+
None,
69+
"--image",
70+
help="Custom image to use (can be full path like 'repo/image:tag' or just tag like '1.7.1')",
71+
),
72+
**options,
73+
) -> None:
3074
"""
31-
Start a remote development environment.
75+
Starts a remote development environment.
76+
77+
This command creates a new VS Code Server remote development environment if it doesn't exist,
78+
or starts an existing one if it's suspended. If the environment is already running, it's a no-op.
79+
The environment is deployed as a Snowpark Container Service that provides
80+
a web-based development environment.
81+
82+
Usage examples:
83+
- Resume existing service: snow remote start myproject
84+
- Create new service: snow remote start --compute-pool my_pool
85+
- Create named service: snow remote start myproject --compute-pool my_pool
3286
33-
This is a placeholder command for the remote plugin.
34-
Full functionality will be implemented in subsequent PRs.
87+
The --compute-pool parameter is only required when creating a new service. For resuming
88+
existing services, the compute pool is not needed.
3589
"""
36-
log.info("Start command called - functionality coming soon!")
37-
log.info("Full functionality will be available in upcoming releases.")
90+
try:
91+
manager = RemoteManager()
92+
93+
service_name, url, status = manager.start(
94+
name=name,
95+
compute_pool=compute_pool,
96+
external_access=eai_name,
97+
stage=stage,
98+
image=image,
99+
)
100+
101+
# Display appropriate success message based on what happened
102+
if status == "created":
103+
cc.message(
104+
f"✓ Remote Development Environment {service_name} created successfully!"
105+
)
106+
elif status == "resumed":
107+
cc.message(
108+
f"✓ Remote Development Environment {service_name} resumed successfully!"
109+
)
110+
elif status == "running":
111+
cc.message(
112+
f"✓ Remote Development Environment {service_name} is already running."
113+
)
114+
115+
cc.message(f"VS Code Server URL: {url}")
116+
117+
# Log detailed information at debug level
118+
if stage:
119+
log.debug("Stage '%s' mounted:", stage)
120+
log.debug(
121+
" - Workspace: '%s/user-default' → '%s'",
122+
stage,
123+
"/home/user/workspace",
124+
)
125+
log.debug(
126+
" - VS Code data: '%s/.vscode-server/data' → '%s'",
127+
stage,
128+
"/home/user/.vscode-server",
129+
)
130+
if eai_name:
131+
log.debug("External access integrations: %s", ", ".join(eai_name))
132+
if image:
133+
log.debug("Using custom image: %s", image)
134+
135+
except ValueError as e:
136+
cc.warning(f"Error: {e}")
137+
raise typer.Exit(code=1)
138+
except Exception as e:
139+
cc.warning(f"Error starting remote environment: {e}")
140+
raise typer.Exit(code=1)
38141

39142

40143
@app.command("list", requires_connection=True)
41-
def list_services(**options) -> None:
144+
def list_services(**options) -> CommandResult:
145+
"""
146+
Lists all remote development environments.
147+
"""
148+
cursor = RemoteManager().list_services()
149+
return QueryResult(cursor)
150+
151+
152+
@app.command("stop", requires_connection=True)
153+
def stop(
154+
name: str = RemoteNameArgument,
155+
**options,
156+
) -> CommandResult:
157+
"""
158+
Suspends a remote development environment.
42159
"""
43-
List remote development environments.
160+
manager = RemoteManager()
161+
cursor = manager.stop(name)
162+
cc.message(f"Remote environment '{name}' suspended successfully.")
163+
return SingleQueryResult(cursor)
44164

45-
This is a placeholder command for the remote plugin.
46-
Full functionality will be implemented in subsequent PRs.
165+
166+
@app.command("delete", requires_connection=True)
167+
def delete(
168+
name: str = RemoteNameArgument,
169+
**options,
170+
) -> CommandResult:
171+
"""
172+
Deletes a remote development environment.
47173
"""
48-
log.info("Remote plugin registered successfully")
49-
log.info("Remote development environments plugin is registered.")
50-
log.info("Full functionality will be available in upcoming releases.")
174+
manager = RemoteManager()
175+
cursor = manager.delete(name)
176+
cc.message(f"Remote environment '{name}' deleted successfully.")
177+
return SingleQueryResult(cursor)

src/snowflake/cli/_plugins/remote/constants.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,24 +54,31 @@ class ComputeResources:
5454
# Service naming constants
5555
SERVICE_NAME_PREFIX = "SNOW_REMOTE"
5656

57-
# Service status constants
58-
SERVICE_STATUS_READY = "READY"
59-
SERVICE_STATUS_SUSPENDED = "SUSPENDED"
60-
SERVICE_STATUS_SUSPENDING = "SUSPENDING"
61-
SERVICE_STATUS_PENDING = "PENDING"
62-
SERVICE_STATUS_STARTING = "STARTING"
63-
SERVICE_STATUS_FAILED = "FAILED"
64-
SERVICE_STATUS_ERROR = "ERROR"
65-
SERVICE_STATUS_UNKNOWN = "UNKNOWN"
66-
67-
# Service operation result constants
68-
SERVICE_RESULT_CREATED = "created"
69-
SERVICE_RESULT_RESUMED = "resumed"
70-
SERVICE_RESULT_RUNNING = "running"
57+
58+
class ServiceStatus(enum.Enum):
59+
"""Service status values returned by SHOW SERVICES and SPCS_WAIT_FOR (service-level)."""
60+
61+
PENDING = "PENDING"
62+
RUNNING = "RUNNING"
63+
FAILED = "FAILED"
64+
DONE = "DONE"
65+
SUSPENDING = "SUSPENDING"
66+
SUSPENDED = "SUSPENDED"
67+
DELETING = "DELETING"
68+
DELETED = "DELETED"
69+
INTERNAL_ERROR = "INTERNAL_ERROR"
70+
71+
72+
class ServiceResult(enum.Enum):
73+
"""Service operation result values."""
74+
75+
CREATED = "created"
76+
RESUMED = "resumed"
77+
RUNNING = "running"
78+
7179

7280
# Default timeout for service operations
7381
DEFAULT_SERVICE_TIMEOUT_MINUTES = 10
74-
STATUS_CHECK_INTERVAL_SECONDS = 10
7582

7683
# Default container image information
7784
DEFAULT_IMAGE_REPO = "/snowflake/images/snowflake_images"

src/snowflake/cli/_plugins/remote/container_spec.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,22 +47,32 @@
4747
ImageSpec,
4848
format_stage_path,
4949
get_node_resources,
50+
parse_image_string,
5051
)
5152

5253

5354
def _get_image_spec(
54-
session: snowpark.Session, compute_pool: str, image_tag: Optional[str] = None
55+
session: snowpark.Session, compute_pool: str, image: Optional[str] = None
5556
) -> ImageSpec:
5657
"""Get appropriate image specification based on compute pool."""
5758
# Retrieve compute pool node resources
5859
resources = get_node_resources(session, compute_pool=compute_pool)
5960

60-
# Use MLRuntime image - select CPU or GPU based on resources
61-
image_repo = DEFAULT_IMAGE_REPO
62-
image_name = DEFAULT_IMAGE_GPU if resources.gpu > 0 else DEFAULT_IMAGE_CPU
63-
64-
# Use provided image_tag or fall back to default
65-
if not image_tag:
61+
# Set defaults
62+
default_image_name = DEFAULT_IMAGE_GPU if resources.gpu > 0 else DEFAULT_IMAGE_CPU
63+
64+
if image:
65+
# Parse the image string
66+
parsed_repo, parsed_image_name, parsed_tag = parse_image_string(image)
67+
68+
# Use parsed values or fall back to defaults
69+
image_repo = parsed_repo or DEFAULT_IMAGE_REPO
70+
image_name = parsed_image_name or default_image_name
71+
image_tag = parsed_tag or DEFAULT_IMAGE_TAG
72+
else:
73+
# Use all defaults
74+
image_repo = DEFAULT_IMAGE_REPO
75+
image_name = default_image_name
6676
image_tag = DEFAULT_IMAGE_TAG
6777

6878
return ImageSpec(
@@ -78,7 +88,7 @@ def generate_service_spec(
7888
session: snowpark.Session,
7989
compute_pool: str,
8090
stage: Optional[str] = None,
81-
image_tag: Optional[str] = None,
91+
image: Optional[str] = None,
8292
ssh_public_key: Optional[str] = None,
8393
) -> Dict[str, Any]:
8494
"""
@@ -88,7 +98,7 @@ def generate_service_spec(
8898
session: Snowflake session
8999
compute_pool: Compute pool for service execution
90100
stage: Optional internal Snowflake stage to mount (e.g., @my_stage)
91-
image_tag: Optional custom image tag to use
101+
image: Optional custom image (can be full path like 'repo/image:tag' or just tag like '1.7.1')
92102
ssh_public_key: Optional SSH public key to inject for secure authentication
93103
94104
Returns:
@@ -99,7 +109,7 @@ def generate_service_spec(
99109
# Normalize and validate the stage path (raises ValueError if invalid)
100110
stage = format_stage_path(stage)
101111

102-
image_spec = _get_image_spec(session, compute_pool, image_tag)
112+
image_spec = _get_image_spec(session, compute_pool, image)
103113

104114
# Set resource requests/limits
105115
# This is a temporary fix to SPCS preprod8 bug to ensure the container has enough memory.
@@ -273,7 +283,7 @@ def generate_service_spec_yaml(
273283
session: snowpark.Session,
274284
compute_pool: str,
275285
stage: Optional[str] = None,
276-
image_tag: Optional[str] = None,
286+
image: Optional[str] = None,
277287
ssh_public_key: Optional[str] = None,
278288
) -> str:
279289
"""
@@ -285,7 +295,7 @@ def generate_service_spec_yaml(
285295
session: Snowflake session
286296
compute_pool: Compute pool for service execution
287297
stage: Optional internal Snowflake stage to mount (e.g., @my_stage)
288-
image_tag: Optional custom image tag to use
298+
image: Optional custom image (can be full path like 'repo/image:tag' or just tag like '1.7.1')
289299
ssh_public_key: Optional SSH public key to inject for secure authentication
290300
291301
Returns:
@@ -295,7 +305,7 @@ def generate_service_spec_yaml(
295305
session=session,
296306
compute_pool=compute_pool,
297307
stage=stage,
298-
image_tag=image_tag,
308+
image=image,
299309
ssh_public_key=ssh_public_key,
300310
)
301311
return yaml.dump(spec, default_flow_style=False)

0 commit comments

Comments
 (0)