Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions sky/client/cli/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
from sky.utils import yaml_utils
from sky.utils.cli_utils import status_utils
from sky.volumes.client import sdk as volumes_sdk
from sky.workspaces import constants as workspace_constants

if typing.TYPE_CHECKING:

Expand Down Expand Up @@ -8130,6 +8131,15 @@ def workspace_info(output_format: str):
])
click.echo('\n'.join(lines))

# AMBIGUOUS is the only state whose recovery message is multi-line
# (5+ lines) — inlining it into `Note:` would break the tree
# alignment, so render it as a separate paragraph below. The text
# comes from `WorkspaceAmbiguousError.recovery_hint()` so the CLI
# and launch-path error message share a single source.
if info.get('source') == workspace_constants.WORKSPACE_SOURCE_AMBIGUOUS:
click.echo()
click.echo(exceptions.WorkspaceAmbiguousError.recovery_hint())


@cli.group(cls=_NaturalOrderGroup)
def ssh():
Expand Down
28 changes: 21 additions & 7 deletions sky/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,24 +476,38 @@ class WorkspaceAmbiguousError(SkyPilotExcludeArgsBaseException):

Carries the list of accessible workspace names so callers (CLI / API
handlers) can format consistent guidance pointing the user at
`sky workspace use <name>`, `--workspace`, or `~/.sky/config.yaml`.
`sky workspace use <name>` or `~/.sky/config.yaml`. The `--workspace`
flag is listed as a footnote because it only exists on the launch
commands (`sky launch` / `sky jobs launch`); listing it as a main
fix would mislead users running `sky status` / `sky queue` / etc.

`note` is an optional drift explanation populated when the user has a
saved preference that is no longer accessible — so the user understands
why their previous default stopped working.
"""

# Recovery guidance shared between the exception message and the
# `sky workspace info` hint paragraph. Kept as a classmethod so the
# two surfaces don't drift; not parameterized on `accessible` because
# both call sites already show the list separately (the exception
# message via the preamble, the CLI via the `Accessible:` tree row).
@classmethod
def recovery_hint(cls) -> str:
return ('SkyPilot can\'t pick one automatically for this command. '
'To proceed:\n'
' - run `sky workspace use <name>` to set your default, or\n'
' - set `active_workspace: <name>` in `~/.sky/config.yaml`.\n'
'\n'
'Or, for a one-shot override on `sky launch` / '
'`sky jobs launch`, pass `--workspace <name>`.')

def __init__(self, accessible: List[str], note: Optional[str] = None):
self.accessible = sorted(accessible)
self.note = note
names = ', '.join(self.accessible)
note_line = f'\nNote: {note}.' if note else ''
super().__init__(
f'You belong to multiple workspaces: {names}.{note_line}\n'
f'To proceed:\n'
f' - run `sky workspace use <name>` to set your default, or\n'
f' - pass `--workspace <name>` on this command, or\n'
f' - set `active_workspace:` in `~/.sky/config.yaml`.')
super().__init__(f'You belong to multiple workspaces: {names}.'
f'{note_line}\n{self.recovery_hint()}')

def __reduce__(self):
# SkyPilot's request executor pickles exceptions raised by a
Expand Down
48 changes: 48 additions & 0 deletions sky/server/requests/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
from sky.utils import timeline
from sky.utils import yaml_utils
from sky.utils.db import db_utils
from sky.workspaces import constants as workspace_constants
from sky.workspaces import core as workspaces_core

if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -355,6 +356,37 @@ def _get_queue(schedule_type: api_requests.ScheduleType) -> RequestQueue:
return RequestQueue(factory.create_queue(schedule_type.value))


# Request names where a non-explicit workspace pick is worth surfacing
# at INFO level (i.e. visible in the streamed CLI output, not just debug
# logs). Resource-creating commands record the resolved workspace into
# durable state (cluster.workspace / job_info.workspace) — users care
# which workspace that ended up being. Read-only commands resolve the
# same way under the hood but the log line would just be noise.
#
# To extend coverage to other resource-creating verbs (e.g. SERVE_UP),
# add the request_name here.
_RESOURCE_CREATING_REQUEST_NAMES_FOR_RESOLUTION_LOG = {
server_constants.REQUEST_NAME_PREFIX +
request_names.RequestName.CLUSTER_LAUNCH.value,
server_constants.REQUEST_NAME_PREFIX +
request_names.RequestName.JOBS_LAUNCH.value,
}

# Sources we DON'T announce, even on a resource-creating request:
# EXPLICIT — the user already named the workspace; repeating
# it in the log is noise.
# DEFAULT_FALLBACK — landing on 'default' is the pre-existing implicit
# behavior; surfacing it on every launch for every
# single-default user would clutter output for the
# common case while telling them nothing new.
# PREFERRED / SINGLE_MEMBERSHIP are the cases worth surfacing — the user
# may not realize where the resource landed.
_SILENT_WORKSPACE_RESOLUTION_SOURCES = {
workspace_constants.WORKSPACE_SOURCE_EXPLICIT,
workspace_constants.WORKSPACE_SOURCE_DEFAULT_FALLBACK,
}


def _should_apply_workspace_resolver(is_daemon: bool,
client_api_version: Optional[int]) -> bool:
"""Returns True iff the per-user workspace resolver should run for
Expand Down Expand Up @@ -488,6 +520,22 @@ def override_request_env_and_config(
logger.debug(f'{request_id} resolved workspace '
f'{resolution.workspace!r} from '
f'{resolution.source} for user {user.name}')
# For resource-creating commands, surface the
# resolver's pick at INFO level so the user sees
# which workspace their cluster / job actually
# landed in. Two filters compose:
# - request_name whitelist (resource-creating verbs)
# - source NOT in the silent set (EXPLICIT /
# DEFAULT_FALLBACK) — EXPLICIT repeats what the
# user just said; DEFAULT_FALLBACK is the silent
# pre-existing behavior. Only PREFERRED /
# SINGLE_MEMBERSHIP are worth surfacing.
if (request_name in
_RESOURCE_CREATING_REQUEST_NAMES_FOR_RESOLUTION_LOG
and resolution.source
not in _SILENT_WORKSPACE_RESOLUTION_SOURCES):
logger.info(f'Using workspace {resolution.workspace!r} '
f'(source: {resolution.source}).')
with workspace_ctx:
try:
# Reject requests that the user does not have
Expand Down
37 changes: 30 additions & 7 deletions sky/users/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from sky.utils import common
from sky.utils import common_utils
from sky.utils import resource_checker
from sky.workspaces import constants as workspace_constants
from sky.workspaces import core as workspaces_core

logger = sky_logging.init_logger(__name__)
Expand Down Expand Up @@ -229,13 +230,35 @@ def get_user_workspace(
try:
resolution = workspaces_core.resolve_workspace_for_user(
user_for_resolve, requested=requested)
except (exceptions.WorkspaceAmbiguousError,
exceptions.NoWorkspaceAccessError,
exceptions.PermissionDeniedError) as e:
# All three are "tell me my state" answers from the user's
# perspective, not server faults. Surface the message in
# `note` and return 200 so callers can render guidance
# uniformly without parsing 4xx bodies.
except exceptions.WorkspaceAmbiguousError as e:
# Per-user state, not a server fault — return 200 with a state-
# coded `source` and a SHORT `note`. The CLI / dashboard show
# the long recovery guidance separately (see
# `WorkspaceAmbiguousError.recovery_hint`) so the structured
# payload (`workspace` / `source` / `note` / `preferred` /
# `accessible`) stays clean and grep-able.
response['source'] = workspace_constants.WORKSPACE_SOURCE_AMBIGUOUS
# `e.note` only carries drift context ("preferred 'X' not
# accessible"); fall back to a generic one-liner otherwise.
response['note'] = (e.note
if e.note else 'multiple workspaces accessible; '
'no preferred or active workspace set')
return response
except exceptions.NoWorkspaceAccessError as e:
# One-line message from the raise site ("User <name> (<id>) has
# no accessible workspaces.") — short enough to fit in the tree
# row and more informative than a generic stand-in.
response['source'] = workspace_constants.WORKSPACE_SOURCE_NO_ACCESS
response['note'] = str(e)
return response
except exceptions.PermissionDeniedError as e:
# Per-workspace deny — raised when an explicit `requested`
# workspace exists but the user can't access it. We keep the
# exception message here because it names the specific
# workspace and the reason (RBAC / not-in-allowed-users),
# which the payload alone wouldn't convey.
response['source'] = (
workspace_constants.WORKSPACE_SOURCE_PERMISSION_DENIED)
response['note'] = str(e)
return response
response['workspace'] = resolution.workspace
Expand Down
29 changes: 29 additions & 0 deletions sky/workspaces/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Workspace protocol constants.

Source codes returned alongside a resolved workspace (and surfaced by
``GET /users/me/workspace``). Kept in their own leaf module — separate
from :mod:`sky.workspaces.core` — so client-side callers (CLI, SDK
docs, dashboard helpers) can compare against them without dragging in
the heavy server-side resolver dependencies (DB, RBAC, backend_utils).
"""

# Sources reported by `resolve_workspace_for_user()` on the success
# path. Used by the launch flow and logged so users / operators can
# trace why a particular workspace was picked.
WORKSPACE_SOURCE_EXPLICIT = 'explicit'
WORKSPACE_SOURCE_PREFERRED = 'preferred'
# Preserves today's behavior for users (and admins) who can access the
# 'default' workspace and have not set a preference — they continue
# landing on 'default' instead of being surprised by an AMBIGUOUS error
# after upgrade.
WORKSPACE_SOURCE_DEFAULT_FALLBACK = 'default-fallback'
WORKSPACE_SOURCE_SINGLE_MEMBERSHIP = 'single-membership'

# Resolver-error states surfaced through `GET /users/me/workspace`. Not
# returned by `resolve_workspace_for_user()` itself (that path raises) —
# the handler writes one of these into the payload so callers (CLI /
# dashboard) can render a state code instead of parsing a multi-line
# English `note`.
WORKSPACE_SOURCE_AMBIGUOUS = 'ambiguous'
WORKSPACE_SOURCE_NO_ACCESS = 'no-access'
WORKSPACE_SOURCE_PERMISSION_DENIED = 'permission-denied'
30 changes: 12 additions & 18 deletions sky/workspaces/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,9 @@
from sky.utils import locks
from sky.utils import resource_checker
from sky.utils import schemas
from sky.workspaces import constants as workspace_constants
from sky.workspaces import utils as workspaces_utils

# Sources reported by resolve_workspace_for_user() so callers (launch path,
# logs) can format consistent provenance strings.
WORKSPACE_SOURCE_EXPLICIT = 'explicit'
WORKSPACE_SOURCE_PREFERRED = 'preferred'
# Preserves today's behavior for users (and admins) who can access the
# 'default' workspace and have not set a preference — they continue landing
# on 'default' instead of being surprised by an AMBIGUOUS error after upgrade.
WORKSPACE_SOURCE_DEFAULT_FALLBACK = 'default-fallback'
WORKSPACE_SOURCE_SINGLE_MEMBERSHIP = 'single-membership'

logger = sky_logging.init_logger(__name__)

# Lock for workspace configuration updates to prevent race conditions
Expand Down Expand Up @@ -1109,8 +1100,9 @@ def resolve_workspace_for_user(
"""
if requested is not None:
check_workspace_permission(user, requested)
return WorkspaceResolution(workspace=requested,
source=WORKSPACE_SOURCE_EXPLICIT)
return WorkspaceResolution(
workspace=requested,
source=workspace_constants.WORKSPACE_SOURCE_EXPLICIT)

accessible = sorted(
_accessible_workspace_names_for_user(user.id,
Expand All @@ -1122,8 +1114,9 @@ def resolve_workspace_for_user(
# users table per request would be redundant on the hot path.
preferred = user.preferred_workspace
if preferred is not None and preferred in accessible:
return WorkspaceResolution(workspace=preferred,
source=WORKSPACE_SOURCE_PREFERRED)
return WorkspaceResolution(
workspace=preferred,
source=workspace_constants.WORKSPACE_SOURCE_PREFERRED)

drift_note: Optional[str] = None
if preferred is not None and preferred not in accessible:
Expand All @@ -1137,13 +1130,14 @@ def resolve_workspace_for_user(
# admins who used to land on 'default' implicitly.
return WorkspaceResolution(
workspace=constants.SKYPILOT_DEFAULT_WORKSPACE,
source=WORKSPACE_SOURCE_DEFAULT_FALLBACK,
source=workspace_constants.WORKSPACE_SOURCE_DEFAULT_FALLBACK,
note=drift_note)

if len(accessible) == 1:
return WorkspaceResolution(workspace=accessible[0],
source=WORKSPACE_SOURCE_SINGLE_MEMBERSHIP,
note=drift_note)
return WorkspaceResolution(
workspace=accessible[0],
source=workspace_constants.WORKSPACE_SOURCE_SINGLE_MEMBERSHIP,
note=drift_note)

if not accessible:
raise exceptions.NoWorkspaceAccessError(
Expand Down
Loading
Loading