skypilot-nightly 1.0.0.dev20250607__py3-none-any.whl → 1.0.0.dev20250610__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 (132) hide show
  1. sky/__init__.py +2 -2
  2. sky/admin_policy.py +3 -0
  3. sky/authentication.py +1 -7
  4. sky/backends/backend_utils.py +18 -2
  5. sky/backends/cloud_vm_ray_backend.py +9 -20
  6. sky/check.py +4 -3
  7. sky/cli.py +6 -9
  8. sky/client/cli.py +6 -9
  9. sky/client/sdk.py +49 -4
  10. sky/clouds/kubernetes.py +15 -24
  11. sky/core.py +3 -2
  12. sky/dashboard/out/404.html +1 -1
  13. sky/dashboard/out/_next/static/4lwUJxN6KwBqUxqO1VccB/_buildManifest.js +1 -0
  14. sky/dashboard/out/_next/static/chunks/211.692afc57e812ae1a.js +1 -0
  15. sky/dashboard/out/_next/static/chunks/350.9e123a4551f68b0d.js +1 -0
  16. sky/dashboard/out/_next/static/chunks/37-d8aebf1683522a0b.js +6 -0
  17. sky/dashboard/out/_next/static/chunks/42.d39e24467181b06b.js +6 -0
  18. sky/dashboard/out/_next/static/chunks/443.b2242d0efcdf5f47.js +1 -0
  19. sky/dashboard/out/_next/static/chunks/470-4d1a5dbe58a8a2b9.js +1 -0
  20. sky/dashboard/out/_next/static/chunks/{121-865d2bf8a3b84c6a.js → 491.b3d264269613fe09.js} +3 -3
  21. sky/dashboard/out/_next/static/chunks/513.211357a2914a34b2.js +1 -0
  22. sky/dashboard/out/_next/static/chunks/600.9cc76ec442b22e10.js +16 -0
  23. sky/dashboard/out/_next/static/chunks/616-d6128fa9e7cae6e6.js +39 -0
  24. sky/dashboard/out/_next/static/chunks/664-047bc03493fda379.js +1 -0
  25. sky/dashboard/out/_next/static/chunks/682.4dd5dc116f740b5f.js +6 -0
  26. sky/dashboard/out/_next/static/chunks/760-a89d354797ce7af5.js +1 -0
  27. sky/dashboard/out/_next/static/chunks/799-3625946b2ec2eb30.js +8 -0
  28. sky/dashboard/out/_next/static/chunks/804-4c9fc53aa74bc191.js +21 -0
  29. sky/dashboard/out/_next/static/chunks/843-6fcc4bf91ac45b39.js +11 -0
  30. sky/dashboard/out/_next/static/chunks/856-0776dc6ed6000c39.js +1 -0
  31. sky/dashboard/out/_next/static/chunks/901-b424d293275e1fd7.js +1 -0
  32. sky/dashboard/out/_next/static/chunks/938-a75b7712639298b7.js +1 -0
  33. sky/dashboard/out/_next/static/chunks/947-6620842ef80ae879.js +35 -0
  34. sky/dashboard/out/_next/static/chunks/969-20d54a9d998dc102.js +1 -0
  35. sky/dashboard/out/_next/static/chunks/973-c807fc34f09c7df3.js +1 -0
  36. sky/dashboard/out/_next/static/chunks/pages/_app-4768de0aede04dc9.js +20 -0
  37. sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]/[job]-89216c616dbaa9c5.js +6 -0
  38. sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]-451a14e7e755ebbc.js +6 -0
  39. sky/dashboard/out/_next/static/chunks/pages/clusters-e56b17fd85d0ba58.js +1 -0
  40. sky/dashboard/out/_next/static/chunks/pages/config-497a35a7ed49734a.js +1 -0
  41. sky/dashboard/out/_next/static/chunks/pages/infra/[context]-d2910be98e9227cb.js +1 -0
  42. sky/dashboard/out/_next/static/chunks/pages/infra-780860bcc1103945.js +1 -0
  43. sky/dashboard/out/_next/static/chunks/pages/jobs/[job]-b3dbf38b51cb29be.js +16 -0
  44. sky/dashboard/out/_next/static/chunks/pages/jobs-fe233baf3d073491.js +1 -0
  45. sky/dashboard/out/_next/static/chunks/pages/users-c69ffcab9d6e5269.js +1 -0
  46. sky/dashboard/out/_next/static/chunks/pages/workspace/new-31aa8bdcb7592635.js +1 -0
  47. sky/dashboard/out/_next/static/chunks/pages/workspaces/[name]-c8c2191328532b7d.js +1 -0
  48. sky/dashboard/out/_next/static/chunks/pages/workspaces-82e6601baa5dd280.js +1 -0
  49. sky/dashboard/out/_next/static/chunks/webpack-0574a5a4ba3cf0ac.js +1 -0
  50. sky/dashboard/out/_next/static/css/8b1c8321d4c02372.css +3 -0
  51. sky/dashboard/out/clusters/[cluster]/[job].html +1 -1
  52. sky/dashboard/out/clusters/[cluster].html +1 -1
  53. sky/dashboard/out/clusters.html +1 -1
  54. sky/dashboard/out/config.html +1 -1
  55. sky/dashboard/out/index.html +1 -1
  56. sky/dashboard/out/infra/[context].html +1 -1
  57. sky/dashboard/out/infra.html +1 -1
  58. sky/dashboard/out/jobs/[job].html +1 -1
  59. sky/dashboard/out/jobs.html +1 -1
  60. sky/dashboard/out/users.html +1 -1
  61. sky/dashboard/out/workspace/new.html +1 -1
  62. sky/dashboard/out/workspaces/[name].html +1 -1
  63. sky/dashboard/out/workspaces.html +1 -1
  64. sky/exceptions.py +23 -0
  65. sky/global_user_state.py +192 -80
  66. sky/jobs/client/sdk.py +29 -21
  67. sky/jobs/server/core.py +9 -1
  68. sky/jobs/server/server.py +0 -95
  69. sky/jobs/utils.py +2 -1
  70. sky/models.py +18 -0
  71. sky/provision/kubernetes/constants.py +9 -0
  72. sky/provision/kubernetes/utils.py +106 -7
  73. sky/serve/client/sdk.py +56 -45
  74. sky/serve/server/core.py +1 -1
  75. sky/server/common.py +5 -7
  76. sky/server/constants.py +0 -2
  77. sky/server/requests/executor.py +60 -22
  78. sky/server/requests/payloads.py +3 -0
  79. sky/server/requests/process.py +69 -29
  80. sky/server/requests/requests.py +4 -3
  81. sky/server/server.py +23 -5
  82. sky/server/stream_utils.py +111 -55
  83. sky/skylet/constants.py +4 -2
  84. sky/skylet/job_lib.py +2 -1
  85. sky/skypilot_config.py +108 -25
  86. sky/users/model.conf +1 -1
  87. sky/users/permission.py +149 -32
  88. sky/users/rbac.py +26 -0
  89. sky/users/server.py +14 -13
  90. sky/utils/admin_policy_utils.py +9 -3
  91. sky/utils/common.py +6 -1
  92. sky/utils/common_utils.py +21 -3
  93. sky/utils/context.py +21 -1
  94. sky/utils/controller_utils.py +16 -1
  95. sky/utils/kubernetes/exec_kubeconfig_converter.py +19 -47
  96. sky/utils/schemas.py +9 -0
  97. sky/workspaces/core.py +100 -8
  98. sky/workspaces/server.py +15 -2
  99. sky/workspaces/utils.py +56 -0
  100. {skypilot_nightly-1.0.0.dev20250607.dist-info → skypilot_nightly-1.0.0.dev20250610.dist-info}/METADATA +1 -1
  101. {skypilot_nightly-1.0.0.dev20250607.dist-info → skypilot_nightly-1.0.0.dev20250610.dist-info}/RECORD +106 -94
  102. sky/dashboard/out/_next/static/1qG0HTmVilJPxQdBk0fX5/_buildManifest.js +0 -1
  103. sky/dashboard/out/_next/static/chunks/236-619ed0248fb6fdd9.js +0 -6
  104. sky/dashboard/out/_next/static/chunks/293-351268365226d251.js +0 -1
  105. sky/dashboard/out/_next/static/chunks/37-600191c5804dcae2.js +0 -6
  106. sky/dashboard/out/_next/static/chunks/470-ad1e0db3afcbd9c9.js +0 -1
  107. sky/dashboard/out/_next/static/chunks/614-635a84e87800f99e.js +0 -66
  108. sky/dashboard/out/_next/static/chunks/682-b60cfdacc15202e8.js +0 -6
  109. sky/dashboard/out/_next/static/chunks/843-c296541442d4af88.js +0 -11
  110. sky/dashboard/out/_next/static/chunks/856-3a32da4b84176f6d.js +0 -1
  111. sky/dashboard/out/_next/static/chunks/969-2c584e28e6b4b106.js +0 -1
  112. sky/dashboard/out/_next/static/chunks/973-6d78a0814682d771.js +0 -1
  113. sky/dashboard/out/_next/static/chunks/pages/_app-cb81dc4d27f4d009.js +0 -1
  114. sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]/[job]-18aed9b56247d074.js +0 -6
  115. sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]-b919a73aecdfa78f.js +0 -6
  116. sky/dashboard/out/_next/static/chunks/pages/clusters-4f6b9dd9abcb33ad.js +0 -1
  117. sky/dashboard/out/_next/static/chunks/pages/config-fe375a56342cf609.js +0 -6
  118. sky/dashboard/out/_next/static/chunks/pages/infra/[context]-3a18d0eeb5119fe4.js +0 -1
  119. sky/dashboard/out/_next/static/chunks/pages/infra-a1a6abeeb58c1051.js +0 -1
  120. sky/dashboard/out/_next/static/chunks/pages/jobs/[job]-1354e28c81eeb686.js +0 -16
  121. sky/dashboard/out/_next/static/chunks/pages/jobs-23bfc8bf373423db.js +0 -1
  122. sky/dashboard/out/_next/static/chunks/pages/users-5800045bd04e69c2.js +0 -16
  123. sky/dashboard/out/_next/static/chunks/pages/workspace/new-e1f9c0c3ff7ac4bd.js +0 -1
  124. sky/dashboard/out/_next/static/chunks/pages/workspaces/[name]-686590e0ee4b2412.js +0 -1
  125. sky/dashboard/out/_next/static/chunks/pages/workspaces-76b07aa5da91b0df.js +0 -1
  126. sky/dashboard/out/_next/static/chunks/webpack-65d465f948974c0d.js +0 -1
  127. sky/dashboard/out/_next/static/css/667d941a2888ce6e.css +0 -3
  128. /sky/dashboard/out/_next/static/{1qG0HTmVilJPxQdBk0fX5 → 4lwUJxN6KwBqUxqO1VccB}/_ssgManifest.js +0 -0
  129. {skypilot_nightly-1.0.0.dev20250607.dist-info → skypilot_nightly-1.0.0.dev20250610.dist-info}/WHEEL +0 -0
  130. {skypilot_nightly-1.0.0.dev20250607.dist-info → skypilot_nightly-1.0.0.dev20250610.dist-info}/entry_points.txt +0 -0
  131. {skypilot_nightly-1.0.0.dev20250607.dist-info → skypilot_nightly-1.0.0.dev20250610.dist-info}/licenses/LICENSE +0 -0
  132. {skypilot_nightly-1.0.0.dev20250607.dist-info → skypilot_nightly-1.0.0.dev20250610.dist-info}/top_level.txt +0 -0
sky/utils/common.py CHANGED
@@ -5,6 +5,7 @@ import enum
5
5
  import os
6
6
  from typing import Generator
7
7
 
8
+ from sky import models
8
9
  from sky.skylet import constants
9
10
  from sky.utils import common_utils
10
11
 
@@ -25,10 +26,13 @@ JOB_CONTROLLER_NAME: str = f'{JOB_CONTROLLER_PREFIX}{SERVER_ID}'
25
26
 
26
27
 
27
28
  @contextlib.contextmanager
28
- def with_server_user_hash() -> Generator[None, None, None]:
29
+ def with_server_user() -> Generator[None, None, None]:
29
30
  """Temporarily set the user hash to common.SERVER_ID."""
30
31
  old_env_user_hash = os.getenv(constants.USER_ID_ENV_VAR)
32
+ # TODO(zhwu): once we have fully moved our code to use `get_current_user()`
33
+ # instead of `common_utils.get_user_hash()`, we can remove the env override.
31
34
  os.environ[constants.USER_ID_ENV_VAR] = SERVER_ID
35
+ common_utils.set_current_user(models.User.get_current_user())
32
36
  try:
33
37
  yield
34
38
  finally:
@@ -36,6 +40,7 @@ def with_server_user_hash() -> Generator[None, None, None]:
36
40
  os.environ[constants.USER_ID_ENV_VAR] = old_env_user_hash
37
41
  else:
38
42
  os.environ.pop(constants.USER_ID_ENV_VAR)
43
+ common_utils.set_current_user(models.User.get_current_user())
39
44
 
40
45
 
41
46
  class StatusRefreshMode(enum.Enum):
sky/utils/common_utils.py CHANGED
@@ -20,6 +20,7 @@ import uuid
20
20
  import jsonschema
21
21
 
22
22
  from sky import exceptions
23
+ from sky import models
23
24
  from sky import sky_logging
24
25
  from sky.adaptors import common as adaptors_common
25
26
  from sky.skylet import constants
@@ -256,11 +257,13 @@ class Backoff:
256
257
  _current_command: Optional[str] = None
257
258
  _current_client_entrypoint: Optional[str] = None
258
259
  _using_remote_api_server: Optional[bool] = None
260
+ _current_user: Optional['models.User'] = None
259
261
 
260
262
 
261
- def set_client_status(client_entrypoint: Optional[str],
262
- client_command: Optional[str],
263
- using_remote_api_server: bool):
263
+ def set_request_context(client_entrypoint: Optional[str],
264
+ client_command: Optional[str],
265
+ using_remote_api_server: bool,
266
+ user: Optional['models.User']):
264
267
  """Override the current client entrypoint and command.
265
268
 
266
269
  This is useful when we are on the SkyPilot API server side and we have a
@@ -269,9 +272,11 @@ def set_client_status(client_entrypoint: Optional[str],
269
272
  global _current_command
270
273
  global _current_client_entrypoint
271
274
  global _using_remote_api_server
275
+ global _current_user
272
276
  _current_command = client_command
273
277
  _current_client_entrypoint = client_entrypoint
274
278
  _using_remote_api_server = using_remote_api_server
279
+ _current_user = user
275
280
 
276
281
 
277
282
  def get_current_command() -> str:
@@ -286,6 +291,19 @@ def get_current_command() -> str:
286
291
  return get_pretty_entrypoint_cmd()
287
292
 
288
293
 
294
+ def get_current_user() -> 'models.User':
295
+ """Returns the current user."""
296
+ if _current_user is not None:
297
+ return _current_user
298
+ return models.User.get_current_user()
299
+
300
+
301
+ def set_current_user(user: 'models.User'):
302
+ """Sets the current user."""
303
+ global _current_user
304
+ _current_user = user
305
+
306
+
289
307
  def get_current_client_entrypoint(server_entrypoint: str) -> str:
290
308
  """Returns the current client entrypoint.
291
309
 
sky/utils/context.py CHANGED
@@ -4,11 +4,13 @@ import asyncio
4
4
  from collections.abc import Mapping
5
5
  from collections.abc import MutableMapping
6
6
  import contextvars
7
+ import functools
7
8
  import os
8
9
  import pathlib
9
10
  import subprocess
10
11
  import sys
11
- from typing import Dict, Optional, TextIO
12
+ import typing
13
+ from typing import Any, Callable, Dict, Optional, TextIO, TypeVar
12
14
 
13
15
 
14
16
  class Context(object):
@@ -256,6 +258,24 @@ class Popen(subprocess.Popen):
256
258
  super().__init__(*args, env=env, **kwargs)
257
259
 
258
260
 
261
+ F = TypeVar('F', bound=Callable[..., Any])
262
+
263
+
264
+ def contextual(func: F) -> F:
265
+ """Decorator to intiailize a context before executing the function.
266
+
267
+ If a context is already initialized, this decorator will reset the context,
268
+ i.e. all contextual variables set previously will be cleared.
269
+ """
270
+
271
+ @functools.wraps(func)
272
+ def wrapper(*args, **kwargs):
273
+ initialize()
274
+ return func(*args, **kwargs)
275
+
276
+ return typing.cast(F, wrapper)
277
+
278
+
259
279
  def initialize():
260
280
  """Initialize the current SkyPilot context."""
261
281
  _CONTEXT.set(Context())
@@ -24,6 +24,7 @@ from sky.clouds import gcp
24
24
  from sky.data import data_utils
25
25
  from sky.data import storage as storage_lib
26
26
  from sky.jobs import constants as managed_job_constants
27
+ from sky.provision.kubernetes import constants as kubernetes_constants
27
28
  from sky.serve import constants as serve_constants
28
29
  from sky.setup_files import dependencies
29
30
  from sky.skylet import constants
@@ -272,6 +273,18 @@ def _get_cloud_dependencies_installation_commands(
272
273
  step_prefix = prefix_str.replace('<step>', str(len(commands) + 1))
273
274
  commands.append(f'echo -en "\\r{step_prefix}GCP SDK{empty_str}" &&'
274
275
  f'{gcp.GOOGLE_SDK_INSTALLATION_COMMAND}')
276
+ if clouds.cloud_in_iterable(clouds.Kubernetes(), enabled_clouds):
277
+ # Install gke-gcloud-auth-plugin used for exec-auth with GKE.
278
+ # We install the plugin here instead of the next elif branch
279
+ # because gcloud is required to install the plugin, so the order
280
+ # of command execution is critical.
281
+
282
+ # We install plugin here regardless of whether exec-auth is
283
+ # actually used as exec-auth may be used in the future.
284
+ # TODO (kyuds): how to implement conservative installation?
285
+ commands.append(
286
+ '(command -v gke-gcloud-auth-plugin &>/dev/null || '
287
+ '(gcloud components install gke-gcloud-auth-plugin --quiet &>/dev/null))') # pylint: disable=line-too-long
275
288
  elif isinstance(cloud, clouds.Kubernetes):
276
289
  step_prefix = prefix_str.replace('<step>', str(len(commands) + 1))
277
290
  commands.append(
@@ -295,7 +308,9 @@ def _get_cloud_dependencies_installation_commands(
295
308
  '(curl -s -LO "https://dl.k8s.io/release/v1.31.6'
296
309
  '/bin/linux/$ARCH/kubectl" && '
297
310
  'sudo install -o root -g root -m 0755 '
298
- 'kubectl /usr/local/bin/kubectl))')
311
+ 'kubectl /usr/local/bin/kubectl)) && '
312
+ f'echo -e \'#!/bin/bash\\nexport PATH="{kubernetes_constants.SKY_K8S_EXEC_AUTH_PATH}"\\nexec "$@"\' | sudo tee /usr/local/bin/{kubernetes_constants.SKY_K8S_EXEC_AUTH_WRAPPER} > /dev/null && ' # pylint: disable=line-too-long
313
+ f'sudo chmod +x /usr/local/bin/{kubernetes_constants.SKY_K8S_EXEC_AUTH_WRAPPER}') # pylint: disable=line-too-long
299
314
  elif isinstance(cloud, clouds.Cudo):
300
315
  step_prefix = prefix_str.replace('<step>', str(len(commands) + 1))
301
316
  commands.append(
@@ -12,6 +12,12 @@ It assumes the target environment has the auth executable available in PATH.
12
12
  If not, you'll need to update your environment container to include the auth
13
13
  executable in PATH.
14
14
 
15
+ When using LOCAL_CREDENTIALS (aka exec auth) with Kubernetes, though, SkyPilot
16
+ will automatically inject a wrapper script for common exec auth providers like
17
+ GKE and EKS. This wrapper script helps to resolve path issues that may arise
18
+ from executables installed on non system-default paths. Thus, the kubeconfig
19
+ file may look different on the sky jobs controller.
20
+
15
21
  Usage:
16
22
  python -m sky.utils.kubernetes.exec_kubeconfig_converter
17
23
  """
@@ -20,52 +26,7 @@ import os
20
26
 
21
27
  import yaml
22
28
 
23
-
24
- def strip_auth_plugin_paths(kubeconfig_path: str, output_path: str):
25
- """Strip path information from exec plugin commands in a kubeconfig file.
26
-
27
- For Nebius kubeconfigs, also changes the --profile argument to 'sky'.
28
-
29
- Args:
30
- kubeconfig_path (str): Path to the input kubeconfig file
31
- output_path (str): Path where the modified kubeconfig will be saved
32
- """
33
- with open(kubeconfig_path, 'r', encoding='utf-8') as file:
34
- config = yaml.safe_load(file)
35
-
36
- updated = False
37
- for user in config.get('users', []):
38
- exec_info = user.get('user', {}).get('exec', {})
39
- current_command = exec_info.get('command', '')
40
-
41
- if current_command:
42
- # Strip the path and keep only the executable name
43
- executable = os.path.basename(current_command)
44
- if executable != current_command:
45
- exec_info['command'] = executable
46
- updated = True
47
-
48
- # Handle Nebius kubeconfigs: change --profile to 'sky'
49
- if executable == 'nebius' or current_command == 'nebius':
50
- args = exec_info.get('args', [])
51
- if args and '--profile' in args:
52
- try:
53
- profile_index = args.index('--profile')
54
- if profile_index + 1 < len(args):
55
- old_profile = args[profile_index + 1]
56
- if old_profile != 'sky':
57
- args[profile_index + 1] = 'sky'
58
- updated = True
59
- except ValueError:
60
- pass # --profile not found in args
61
-
62
- if updated:
63
- with open(output_path, 'w', encoding='utf-8') as file:
64
- yaml.safe_dump(config, file)
65
- print('Kubeconfig updated with path-less exec auth. '
66
- f'Saved to {output_path}')
67
- else:
68
- print('No updates made. No exec-based auth commands paths found.')
29
+ from sky.provision.kubernetes import utils as kubernetes_utils
69
30
 
70
31
 
71
32
  def main():
@@ -85,7 +46,18 @@ def main():
85
46
  help='Output kubeconfig file path (default: %(default)s)')
86
47
 
87
48
  args = parser.parse_args()
88
- strip_auth_plugin_paths(args.input, args.output)
49
+
50
+ with open(args.input, 'r', encoding='utf-8') as file:
51
+ config = yaml.safe_load(file)
52
+
53
+ updated = kubernetes_utils.format_kubeconfig_exec_auth(
54
+ config, args.output, False)
55
+
56
+ if updated:
57
+ print('Kubeconfig updated with path-less exec auth. '
58
+ f'Saved to {args.output}')
59
+ else:
60
+ print('No updates made.')
89
61
 
90
62
 
91
63
  if __name__ == '__main__':
sky/utils/schemas.py CHANGED
@@ -1249,6 +1249,15 @@ def get_config_schema():
1249
1249
  'properties': {
1250
1250
  # Explicit definition for GCP allows both project_id and
1251
1251
  # disabled
1252
+ 'private': {
1253
+ 'type': 'boolean',
1254
+ },
1255
+ 'allowed_users': {
1256
+ 'type': 'array',
1257
+ 'items': {
1258
+ 'type': 'string',
1259
+ },
1260
+ },
1252
1261
  'gcp': {
1253
1262
  'type': 'object',
1254
1263
  'properties': {
sky/workspaces/core.py CHANGED
@@ -1,20 +1,24 @@
1
1
  """Workspace management core."""
2
2
 
3
3
  import concurrent.futures
4
- from typing import Any, Callable, Dict
4
+ from typing import Any, Callable, Dict, List
5
5
 
6
6
  import filelock
7
7
 
8
8
  from sky import check as sky_check
9
9
  from sky import exceptions
10
10
  from sky import global_user_state
11
+ from sky import models
11
12
  from sky import sky_logging
12
13
  from sky import skypilot_config
13
14
  from sky.skylet import constants
14
15
  from sky.usage import usage_lib
16
+ from sky.users import permission
17
+ from sky.utils import annotations
15
18
  from sky.utils import common_utils
16
19
  from sky.utils import config_utils
17
20
  from sky.utils import schemas
21
+ from sky.workspaces import utils as workspaces_utils
18
22
 
19
23
  logger = sky_logging.init_logger(__name__)
20
24
 
@@ -28,10 +32,7 @@ _WORKSPACE_CONFIG_LOCK_TIMEOUT_SECONDS = 60
28
32
 
29
33
  def get_workspaces() -> Dict[str, Any]:
30
34
  """Returns the workspace config."""
31
- workspaces = skypilot_config.get_nested(('workspaces',), default_value={})
32
- if constants.SKYPILOT_DEFAULT_WORKSPACE not in workspaces:
33
- workspaces[constants.SKYPILOT_DEFAULT_WORKSPACE] = {}
34
- return workspaces
35
+ return workspaces_for_user(common_utils.get_current_user().id)
35
36
 
36
37
 
37
38
  def _update_workspaces_config(
@@ -160,7 +161,7 @@ def _check_workspaces_have_no_active_resources(
160
161
  f'{len(workspace_clusters)} active cluster(s): {cluster_list}')
161
162
 
162
163
  if workspace_active_jobs:
163
- job_names = [job['job_id'] for job in workspace_active_jobs]
164
+ job_names = [str(job['job_id']) for job in workspace_active_jobs]
164
165
  job_list = ', '.join(job_names)
165
166
  workspace_errors.append(
166
167
  f'{len(workspace_active_jobs)} active managed job(s): '
@@ -223,14 +224,19 @@ def update_workspace(workspace_name: str, config: Dict[str,
223
224
  FileNotFoundError: If the config file cannot be found.
224
225
  PermissionError: If the config file cannot be written.
225
226
  """
227
+ _validate_workspace_config(workspace_name, config)
228
+
226
229
  # Check for active clusters and managed jobs in the workspace
230
+ # TODO(zhwu): we should allow the edits that only contain changes to
231
+ # allowed_users or private.
227
232
  _check_workspace_has_no_active_resources(workspace_name, 'update')
228
233
 
229
- _validate_workspace_config(workspace_name, config)
230
-
231
234
  def update_workspace_fn(workspaces: Dict[str, Any]) -> None:
232
235
  """Function to update workspace inside the lock."""
233
236
  workspaces[workspace_name] = config
237
+ users = workspaces_utils.get_workspace_users(config)
238
+ permission_service = permission.permission_service
239
+ permission_service.update_workspace_policy(workspace_name, users)
234
240
 
235
241
  # Use the internal helper function to save
236
242
  result = _update_workspaces_config(update_workspace_fn)
@@ -275,6 +281,10 @@ def create_workspace(workspace_name: str, config: Dict[str,
275
281
  raise ValueError(f'Workspace {workspace_name!r} already exists. '
276
282
  'Use update instead.')
277
283
  workspaces[workspace_name] = config
284
+ # Add policy for the workspace and allowed users
285
+ users = workspaces_utils.get_workspace_users(config)
286
+ permission_service = permission.permission_service
287
+ permission_service.add_workspace_policy(workspace_name, users)
278
288
 
279
289
  # Use the internal helper function to save
280
290
  result = _update_workspaces_config(create_workspace_fn)
@@ -324,6 +334,8 @@ def delete_workspace(workspace_name: str) -> Dict[str, Any]:
324
334
  if workspace_name not in workspaces:
325
335
  raise ValueError(f'Workspace {workspace_name!r} does not exist.')
326
336
  del workspaces[workspace_name]
337
+ permission_service = permission.permission_service
338
+ permission_service.remove_workspace_policy(workspace_name)
327
339
 
328
340
  # Use the internal helper function to save
329
341
  return _update_workspaces_config(delete_workspace_fn)
@@ -369,6 +381,9 @@ def update_config(config: Dict[str, Any]) -> Dict[str, Any]:
369
381
 
370
382
  # Check for API server changes and validate them
371
383
  current_config = skypilot_config.to_dict()
384
+ # If there is no changes to the config, we can return early
385
+ if current_config == config:
386
+ return config
372
387
 
373
388
  current_endpoint = current_config.get('api_server', {}).get('endpoint')
374
389
  new_endpoint = config.get('api_server', {}).get('endpoint')
@@ -382,15 +397,27 @@ def update_config(config: Dict[str, Any]) -> Dict[str, Any]:
382
397
 
383
398
  # Collect all workspaces that need to be checked for active resources
384
399
  workspaces_to_check = []
400
+ workspaces_to_check_policy: Dict[str, Dict[str, List[str]]] = {
401
+ 'add': {},
402
+ 'update': {},
403
+ 'delete': {}
404
+ }
385
405
 
386
406
  # Check each workspace that is being modified
387
407
  for workspace_name, new_workspace_config in new_workspaces.items():
408
+ if workspace_name not in current_workspaces:
409
+ users = workspaces_utils.get_workspace_users(new_workspace_config)
410
+ workspaces_to_check_policy['add'][workspace_name] = users
411
+ continue
412
+
388
413
  current_workspace_config = current_workspaces.get(workspace_name, {})
389
414
 
390
415
  # If workspace configuration is changing, validate and mark for checking
391
416
  if current_workspace_config != new_workspace_config:
392
417
  _validate_workspace_config(workspace_name, new_workspace_config)
393
418
  workspaces_to_check.append((workspace_name, 'update'))
419
+ users = workspaces_utils.get_workspace_users(new_workspace_config)
420
+ workspaces_to_check_policy['update'][workspace_name] = users
394
421
 
395
422
  # Check for workspace deletions
396
423
  for workspace_name in current_workspaces:
@@ -400,6 +427,7 @@ def update_config(config: Dict[str, Any]) -> Dict[str, Any]:
400
427
  raise ValueError(f'Cannot delete the default workspace '
401
428
  f'{constants.SKYPILOT_DEFAULT_WORKSPACE!r}.')
402
429
  workspaces_to_check.append((workspace_name, 'delete'))
430
+ workspaces_to_check_policy['delete'][workspace_name] = ['*']
403
431
 
404
432
  # Check all workspaces for active resources in one efficient call
405
433
  _check_workspaces_have_no_active_resources(workspaces_to_check)
@@ -412,6 +440,18 @@ def update_config(config: Dict[str, Any]) -> Dict[str, Any]:
412
440
  # Convert to config_utils.Config and save
413
441
  config_obj = config_utils.Config.from_dict(config)
414
442
  skypilot_config.update_api_server_config_no_lock(config_obj)
443
+ permission_service = permission.permission_service
444
+ for operation, workspaces in workspaces_to_check_policy.items():
445
+ for workspace_name, users in workspaces.items():
446
+ if operation == 'add':
447
+ permission_service.add_workspace_policy(
448
+ workspace_name, users)
449
+ elif operation == 'update':
450
+ permission_service.update_workspace_policy(
451
+ workspace_name, users)
452
+ elif operation == 'delete':
453
+ permission_service.remove_workspace_policy(
454
+ workspace_name)
415
455
  except filelock.Timeout as e:
416
456
  raise RuntimeError(
417
457
  f'Failed to update configuration due to a timeout '
@@ -429,3 +469,55 @@ def update_config(config: Dict[str, Any]) -> Dict[str, Any]:
429
469
  # Don't fail the update if the check fails, just warn
430
470
 
431
471
  return config
472
+
473
+
474
+ def reject_request_for_unauthorized_workspace(user: models.User) -> None:
475
+ """Rejects a request that has no permission to access active workspace.
476
+
477
+ Args:
478
+ user: The user making the request.
479
+
480
+ Raises:
481
+ PermissionDeniedError: If the user does not have permission to access
482
+ the active workspace.
483
+ """
484
+ active_workspace = skypilot_config.get_active_workspace()
485
+ if not permission.permission_service.check_workspace_permission(
486
+ user.id, active_workspace):
487
+ raise exceptions.PermissionDeniedError(
488
+ f'User {user.name} ({user.id}) does not have '
489
+ f'permission to access workspace {active_workspace!r}')
490
+
491
+
492
+ def is_workspace_private(workspace_config: Dict[str, Any]) -> bool:
493
+ """Check if a workspace is private.
494
+
495
+ Args:
496
+ workspace_config: The workspace configuration dictionary.
497
+
498
+ Returns:
499
+ True if the workspace is private, False if it's public.
500
+ """
501
+ return workspace_config.get('private', False)
502
+
503
+
504
+ @annotations.lru_cache(scope='request', maxsize=1)
505
+ def workspaces_for_user(user_id: str) -> Dict[str, Any]:
506
+ """Returns the workspaces that the user has access to.
507
+
508
+ Args:
509
+ user_id: The user id to check.
510
+
511
+ Returns:
512
+ A map from workspace name to workspace configuration.
513
+ """
514
+ workspaces = skypilot_config.get_nested(('workspaces',), default_value={})
515
+ if constants.SKYPILOT_DEFAULT_WORKSPACE not in workspaces:
516
+ workspaces[constants.SKYPILOT_DEFAULT_WORKSPACE] = {}
517
+ user_workspaces = {}
518
+
519
+ for workspace_name, workspace_config in workspaces.items():
520
+ if permission.permission_service.check_workspace_permission(
521
+ user_id, workspace_name):
522
+ user_workspaces[workspace_name] = workspace_config
523
+ return user_workspaces
sky/workspaces/server.py CHANGED
@@ -14,10 +14,18 @@ router = fastapi.APIRouter()
14
14
  # pylint: disable=redefined-builtin
15
15
  async def get(request: fastapi.Request) -> None:
16
16
  """Gets workspace config on the server."""
17
+ # Have to manually inject user info into the request body because the
18
+ # request body is not available in the GET endpoint.
19
+ auth_user = request.state.auth_user
20
+ auth_user_env_vars_kwargs = {
21
+ 'env_vars': auth_user.to_env_vars()
22
+ } if auth_user else {}
23
+ request_body = payloads.RequestBody(**auth_user_env_vars_kwargs)
24
+
17
25
  executor.schedule_request(
18
26
  request_id=request.state.request_id,
19
27
  request_name='workspaces.get',
20
- request_body=payloads.RequestBody(),
28
+ request_body=request_body,
21
29
  func=core.get_workspaces,
22
30
  schedule_type=api_requests.ScheduleType.SHORT,
23
31
  )
@@ -65,10 +73,15 @@ async def delete(request: fastapi.Request,
65
73
  @router.get('/config')
66
74
  async def get_config(request: fastapi.Request) -> None:
67
75
  """Gets the entire SkyPilot configuration."""
76
+ auth_user = request.state.auth_user
77
+ auth_user_env_vars_kwargs = {
78
+ 'env_vars': auth_user.to_env_vars()
79
+ } if auth_user else {}
80
+ get_config_body = payloads.GetConfigBody(**auth_user_env_vars_kwargs)
68
81
  executor.schedule_request(
69
82
  request_id=request.state.request_id,
70
83
  request_name='workspaces.get_config',
71
- request_body=payloads.GetConfigBody(),
84
+ request_body=get_config_body,
72
85
  func=core.get_config,
73
86
  schedule_type=api_requests.ScheduleType.SHORT,
74
87
  )
@@ -0,0 +1,56 @@
1
+ """Utils for workspaces."""
2
+ import collections
3
+ from typing import Any, Dict, List
4
+
5
+ from sky import global_user_state
6
+ from sky import sky_logging
7
+
8
+ logger = sky_logging.init_logger(__name__)
9
+
10
+
11
+ def get_workspace_users(workspace_config: Dict[str, Any]) -> List[str]:
12
+ """Get the users that should have access to a workspace.
13
+
14
+ workspace_config is a dict with the following keys:
15
+ - private: bool
16
+ - allowed_users: list of user names or IDs
17
+
18
+ This function will automatically resolve the user names to IDs.
19
+
20
+ Args:
21
+ workspace_config: The configuration of the workspace.
22
+
23
+ Returns:
24
+ List of user IDs that should have access to the workspace.
25
+ For private workspaces, returns specific user IDs.
26
+ For public workspaces, returns ['*'] to indicate all users.
27
+ """
28
+ if workspace_config.get('private', False):
29
+ user_ids = []
30
+ workspace_user_name_or_ids = workspace_config.get('allowed_users', [])
31
+ all_users = global_user_state.get_all_users()
32
+ all_user_ids = {user.id for user in all_users}
33
+ all_user_map = collections.defaultdict(list)
34
+ for user in all_users:
35
+ all_user_map[user.name].append(user.id)
36
+
37
+ # Resolve user names to IDs
38
+ for user_name_or_id in workspace_user_name_or_ids:
39
+ if user_name_or_id in all_user_ids:
40
+ user_ids.append(user_name_or_id)
41
+ elif user_name_or_id in all_user_map:
42
+ if len(all_user_map[user_name_or_id]) > 1:
43
+ user_ids_str = ', '.join(all_user_map[user_name_or_id])
44
+ raise ValueError(
45
+ f'User {user_name_or_id!r} has multiple IDs: '
46
+ f'{user_ids_str}. Please specify the user '
47
+ f'ID instead.')
48
+ user_ids.append(all_user_map[user_name_or_id][0])
49
+ else:
50
+ logger.warning(
51
+ f'User {user_name_or_id!r} not found in all users')
52
+ continue
53
+ return user_ids
54
+ else:
55
+ # Public workspace - return '*' to indicate all users should have access
56
+ return ['*']
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skypilot-nightly
3
- Version: 1.0.0.dev20250607
3
+ Version: 1.0.0.dev20250610
4
4
  Summary: SkyPilot: Run AI on Any Infra — Unified, Faster, Cheaper.
5
5
  Author: SkyPilot Team
6
6
  License: Apache 2.0