skypilot-nightly 1.0.0.dev20250627__py3-none-any.whl → 1.0.0.dev20250630__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 (173) hide show
  1. sky/__init__.py +2 -2
  2. sky/adaptors/kubernetes.py +14 -0
  3. sky/adaptors/nebius.py +2 -2
  4. sky/authentication.py +12 -5
  5. sky/backends/backend_utils.py +92 -26
  6. sky/check.py +5 -2
  7. sky/client/cli/command.py +39 -8
  8. sky/client/sdk.py +217 -167
  9. sky/client/service_account_auth.py +47 -0
  10. sky/clouds/aws.py +10 -4
  11. sky/clouds/azure.py +5 -2
  12. sky/clouds/cloud.py +5 -2
  13. sky/clouds/gcp.py +31 -18
  14. sky/clouds/kubernetes.py +54 -34
  15. sky/clouds/nebius.py +8 -2
  16. sky/clouds/ssh.py +5 -2
  17. sky/clouds/utils/aws_utils.py +10 -4
  18. sky/clouds/utils/gcp_utils.py +22 -7
  19. sky/clouds/utils/oci_utils.py +62 -14
  20. sky/dashboard/out/404.html +1 -1
  21. sky/dashboard/out/_next/static/NdypbqMxaYucRGfopkKXa/_buildManifest.js +1 -0
  22. sky/dashboard/out/_next/static/chunks/1043-1b39779691bb4030.js +1 -0
  23. sky/dashboard/out/_next/static/chunks/{141-fa5a20cbf401b351.js → 1141-726e5a3f00b67185.js} +2 -2
  24. sky/dashboard/out/_next/static/chunks/1272-1ef0bf0237faccdb.js +1 -0
  25. sky/dashboard/out/_next/static/chunks/1664-d65361e92b85e786.js +1 -0
  26. sky/dashboard/out/_next/static/chunks/1691.44e378727a41f3b5.js +21 -0
  27. sky/dashboard/out/_next/static/chunks/1871-80dea41717729fa5.js +6 -0
  28. sky/dashboard/out/_next/static/chunks/2544.27f70672535675ed.js +1 -0
  29. sky/dashboard/out/_next/static/chunks/{875.52c962183328b3f2.js → 2875.c24c6d57dc82e436.js} +1 -1
  30. sky/dashboard/out/_next/static/chunks/3256.7257acd01b481bed.js +11 -0
  31. sky/dashboard/out/_next/static/chunks/3698-52ad1ca228faa776.js +1 -0
  32. sky/dashboard/out/_next/static/chunks/3785.b3cc2bc1d49d2c3c.js +1 -0
  33. sky/dashboard/out/_next/static/chunks/3937.d7f1c55d1916c7f2.js +1 -0
  34. sky/dashboard/out/_next/static/chunks/{947-6620842ef80ae879.js → 3947-b059261d6fa88a1f.js} +1 -1
  35. sky/dashboard/out/_next/static/chunks/{697.6460bf72e760addd.js → 4697.f5421144224da9fc.js} +1 -1
  36. sky/dashboard/out/_next/static/chunks/4725.4c849b1e05c8e9ad.js +1 -0
  37. sky/dashboard/out/_next/static/chunks/5230-df791914b54d91d9.js +1 -0
  38. sky/dashboard/out/_next/static/chunks/{491.b3d264269613fe09.js → 5491.918ffed0ba7a5294.js} +1 -1
  39. sky/dashboard/out/_next/static/chunks/5739-5ea3ffa10fc884f2.js +8 -0
  40. sky/dashboard/out/_next/static/chunks/616-162f3033ffcd3d31.js +39 -0
  41. sky/dashboard/out/_next/static/chunks/6601-fcfad0ddf92ec7ab.js +1 -0
  42. sky/dashboard/out/_next/static/chunks/6989-6ff4e45dfb49d11d.js +1 -0
  43. sky/dashboard/out/_next/static/chunks/6990-d0dc765474fa0eca.js +1 -0
  44. sky/dashboard/out/_next/static/chunks/8969-909d53833da080cb.js +1 -0
  45. sky/dashboard/out/_next/static/chunks/8982.a2e214068f30a857.js +1 -0
  46. sky/dashboard/out/_next/static/chunks/{25.76c246239df93d50.js → 9025.a7c44babfe56ce09.js} +2 -2
  47. sky/dashboard/out/_next/static/chunks/938-044ad21de8b4626b.js +1 -0
  48. sky/dashboard/out/_next/static/chunks/9470-21d059a1dfa03f61.js +1 -0
  49. sky/dashboard/out/_next/static/chunks/9984.739ae958a066298d.js +1 -0
  50. sky/dashboard/out/_next/static/chunks/fd9d1056-61f2257a9cd8b32b.js +1 -0
  51. sky/dashboard/out/_next/static/chunks/{framework-87d061ee6ed71b28.js → framework-efc06c2733009cd3.js} +1 -1
  52. sky/dashboard/out/_next/static/chunks/main-app-68c028b1bc5e1b72.js +1 -0
  53. sky/dashboard/out/_next/static/chunks/{main-e0e2335212e72357.js → main-c0a4f1ea606d48d2.js} +1 -1
  54. sky/dashboard/out/_next/static/chunks/pages/{_app-9a3ce3170d2edcec.js → _app-a37b06ddb64521fd.js} +2 -2
  55. sky/dashboard/out/_next/static/chunks/pages/_error-c72a1f77a3c0be1b.js +1 -0
  56. sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]/[job]-8135aba0712bda37.js +6 -0
  57. sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]-b8e1114e6d38218c.js +6 -0
  58. sky/dashboard/out/_next/static/chunks/pages/clusters-9744c271a1642f76.js +1 -0
  59. sky/dashboard/out/_next/static/chunks/pages/config-a2673b256b6d416f.js +1 -0
  60. sky/dashboard/out/_next/static/chunks/pages/index-927ddeebe57a8ac3.js +1 -0
  61. sky/dashboard/out/_next/static/chunks/pages/infra/[context]-8b0809f59034d509.js +1 -0
  62. sky/dashboard/out/_next/static/chunks/pages/infra-ae9d2f705ce582c9.js +1 -0
  63. sky/dashboard/out/_next/static/chunks/pages/jobs/[job]-c4d5cfac7fbc0668.js +16 -0
  64. sky/dashboard/out/_next/static/chunks/pages/jobs-5bbdc71878f0a068.js +1 -0
  65. sky/dashboard/out/_next/static/chunks/pages/users-cd43fb3c122eedde.js +1 -0
  66. sky/dashboard/out/_next/static/chunks/pages/volumes-4ebf6484f7216387.js +1 -0
  67. sky/dashboard/out/_next/static/chunks/pages/workspace/new-5629d4e551dba1ee.js +1 -0
  68. sky/dashboard/out/_next/static/chunks/pages/workspaces/[name]-7c0187f43757a548.js +1 -0
  69. sky/dashboard/out/_next/static/chunks/pages/workspaces-06bde99155fa6292.js +1 -0
  70. sky/dashboard/out/_next/static/chunks/webpack-d427db53e54de9ce.js +1 -0
  71. sky/dashboard/out/_next/static/css/0da6afe66176678a.css +3 -0
  72. sky/dashboard/out/clusters/[cluster]/[job].html +1 -1
  73. sky/dashboard/out/clusters/[cluster].html +1 -1
  74. sky/dashboard/out/clusters.html +1 -1
  75. sky/dashboard/out/config.html +1 -1
  76. sky/dashboard/out/index.html +1 -1
  77. sky/dashboard/out/infra/[context].html +1 -1
  78. sky/dashboard/out/infra.html +1 -1
  79. sky/dashboard/out/jobs/[job].html +1 -1
  80. sky/dashboard/out/jobs.html +1 -1
  81. sky/dashboard/out/users.html +1 -1
  82. sky/dashboard/out/volumes.html +1 -1
  83. sky/dashboard/out/workspace/new.html +1 -1
  84. sky/dashboard/out/workspaces/[name].html +1 -1
  85. sky/dashboard/out/workspaces.html +1 -1
  86. sky/data/storage.py +8 -3
  87. sky/global_user_state.py +257 -9
  88. sky/jobs/client/sdk.py +20 -25
  89. sky/models.py +16 -0
  90. sky/optimizer.py +46 -0
  91. sky/provision/__init__.py +14 -6
  92. sky/provision/kubernetes/config.py +1 -1
  93. sky/provision/kubernetes/constants.py +9 -0
  94. sky/provision/kubernetes/instance.py +24 -18
  95. sky/provision/kubernetes/network.py +15 -9
  96. sky/provision/kubernetes/network_utils.py +42 -23
  97. sky/provision/kubernetes/utils.py +73 -35
  98. sky/provision/kubernetes/volume.py +77 -15
  99. sky/provision/nebius/utils.py +10 -4
  100. sky/resources.py +10 -4
  101. sky/serve/client/sdk.py +28 -34
  102. sky/server/common.py +51 -3
  103. sky/server/constants.py +3 -0
  104. sky/server/requests/executor.py +4 -0
  105. sky/server/requests/payloads.py +33 -0
  106. sky/server/requests/requests.py +19 -0
  107. sky/server/rest.py +6 -15
  108. sky/server/server.py +121 -6
  109. sky/skylet/constants.py +7 -0
  110. sky/skypilot_config.py +32 -4
  111. sky/task.py +12 -0
  112. sky/users/permission.py +29 -0
  113. sky/users/server.py +384 -5
  114. sky/users/token_service.py +196 -0
  115. sky/utils/common_utils.py +4 -5
  116. sky/utils/config_utils.py +41 -0
  117. sky/utils/controller_utils.py +5 -1
  118. sky/utils/log_utils.py +68 -0
  119. sky/utils/resource_checker.py +153 -0
  120. sky/utils/resources_utils.py +12 -4
  121. sky/utils/schemas.py +87 -60
  122. sky/utils/subprocess_utils.py +2 -6
  123. sky/volumes/server/core.py +103 -78
  124. sky/volumes/utils.py +22 -5
  125. sky/workspaces/core.py +9 -117
  126. {skypilot_nightly-1.0.0.dev20250627.dist-info → skypilot_nightly-1.0.0.dev20250630.dist-info}/METADATA +1 -1
  127. {skypilot_nightly-1.0.0.dev20250627.dist-info → skypilot_nightly-1.0.0.dev20250630.dist-info}/RECORD +133 -128
  128. sky/dashboard/out/_next/static/HudU4f4Xsy-cP51JvXSZ-/_buildManifest.js +0 -1
  129. sky/dashboard/out/_next/static/chunks/230-d6e363362017ff3a.js +0 -1
  130. sky/dashboard/out/_next/static/chunks/43-36177d00f6956ab2.js +0 -1
  131. sky/dashboard/out/_next/static/chunks/470-92dd1614396389be.js +0 -1
  132. sky/dashboard/out/_next/static/chunks/544.110e53813fb98e2e.js +0 -1
  133. sky/dashboard/out/_next/static/chunks/616-d6128fa9e7cae6e6.js +0 -39
  134. sky/dashboard/out/_next/static/chunks/645.961f08e39b8ce447.js +0 -1
  135. sky/dashboard/out/_next/static/chunks/664-047bc03493fda379.js +0 -1
  136. sky/dashboard/out/_next/static/chunks/690.55f9eed3be903f56.js +0 -16
  137. sky/dashboard/out/_next/static/chunks/785.dc2686c3c1235554.js +0 -1
  138. sky/dashboard/out/_next/static/chunks/798-c0525dc3f21e488d.js +0 -1
  139. sky/dashboard/out/_next/static/chunks/799-3625946b2ec2eb30.js +0 -8
  140. sky/dashboard/out/_next/static/chunks/871-3db673be3ee3750b.js +0 -6
  141. sky/dashboard/out/_next/static/chunks/937.3759f538f11a0953.js +0 -1
  142. sky/dashboard/out/_next/static/chunks/938-068520cc11738deb.js +0 -1
  143. sky/dashboard/out/_next/static/chunks/969-d3a0b53f728d280a.js +0 -1
  144. sky/dashboard/out/_next/static/chunks/973-81b2d057178adb76.js +0 -1
  145. sky/dashboard/out/_next/static/chunks/982.1b61658204416b0f.js +0 -1
  146. sky/dashboard/out/_next/static/chunks/984.e8bac186a24e5178.js +0 -1
  147. sky/dashboard/out/_next/static/chunks/989-db34c16ad7ea6155.js +0 -1
  148. sky/dashboard/out/_next/static/chunks/990-0ad5ea1699e03ee8.js +0 -1
  149. sky/dashboard/out/_next/static/chunks/fd9d1056-2821b0f0cabcd8bd.js +0 -1
  150. sky/dashboard/out/_next/static/chunks/main-app-241eb28595532291.js +0 -1
  151. sky/dashboard/out/_next/static/chunks/pages/_error-1be831200e60c5c0.js +0 -1
  152. sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]/[job]-aff040d7bc5d0086.js +0 -6
  153. sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]-8040f2483897ed0c.js +0 -6
  154. sky/dashboard/out/_next/static/chunks/pages/clusters-f119a5630a1efd61.js +0 -1
  155. sky/dashboard/out/_next/static/chunks/pages/config-6b255eae088da6a3.js +0 -1
  156. sky/dashboard/out/_next/static/chunks/pages/index-6b0d9e5031b70c58.js +0 -1
  157. sky/dashboard/out/_next/static/chunks/pages/infra/[context]-b302aea4d65766bf.js +0 -1
  158. sky/dashboard/out/_next/static/chunks/pages/infra-ee8cc4d449945d19.js +0 -1
  159. sky/dashboard/out/_next/static/chunks/pages/jobs/[job]-e4b23128db0774cd.js +0 -16
  160. sky/dashboard/out/_next/static/chunks/pages/jobs-0a5695ff3075d94a.js +0 -1
  161. sky/dashboard/out/_next/static/chunks/pages/users-4978cbb093e141e7.js +0 -1
  162. sky/dashboard/out/_next/static/chunks/pages/volumes-476b670ef33d1ecd.js +0 -1
  163. sky/dashboard/out/_next/static/chunks/pages/workspace/new-5b59bce9eb208d84.js +0 -1
  164. sky/dashboard/out/_next/static/chunks/pages/workspaces/[name]-cb7e720b739de53a.js +0 -1
  165. sky/dashboard/out/_next/static/chunks/pages/workspaces-50e230828730cfb3.js +0 -1
  166. sky/dashboard/out/_next/static/chunks/webpack-08fdb9e6070127fc.js +0 -1
  167. sky/dashboard/out/_next/static/css/52082cf558ec9705.css +0 -3
  168. /sky/dashboard/out/_next/static/{HudU4f4Xsy-cP51JvXSZ- → NdypbqMxaYucRGfopkKXa}/_ssgManifest.js +0 -0
  169. /sky/dashboard/out/_next/static/chunks/{804-4c9fc53aa74bc191.js → 804-9f5e98ce84d46bdd.js} +0 -0
  170. {skypilot_nightly-1.0.0.dev20250627.dist-info → skypilot_nightly-1.0.0.dev20250630.dist-info}/WHEEL +0 -0
  171. {skypilot_nightly-1.0.0.dev20250627.dist-info → skypilot_nightly-1.0.0.dev20250630.dist-info}/entry_points.txt +0 -0
  172. {skypilot_nightly-1.0.0.dev20250627.dist-info → skypilot_nightly-1.0.0.dev20250630.dist-info}/licenses/LICENSE +0 -0
  173. {skypilot_nightly-1.0.0.dev20250627.dist-info → skypilot_nightly-1.0.0.dev20250630.dist-info}/top_level.txt +0 -0
sky/skylet/constants.py CHANGED
@@ -346,6 +346,11 @@ API_SERVER_CREATION_LOCK_PATH = '~/.sky/api_server/.creation.lock'
346
346
  # API server.
347
347
  SKY_API_SERVER_URL_ENV_VAR = f'{SKYPILOT_ENV_VAR_PREFIX}API_SERVER_ENDPOINT'
348
348
 
349
+ # The name for the environment variable that stores the SkyPilot service
350
+ # account token on client side.
351
+ SERVICE_ACCOUNT_TOKEN_ENV_VAR = (
352
+ f'{SKYPILOT_ENV_VAR_PREFIX}SERVICE_ACCOUNT_TOKEN')
353
+
349
354
  # SkyPilot environment variables
350
355
  SKYPILOT_NUM_NODES = f'{SKYPILOT_ENV_VAR_PREFIX}NUM_NODES'
351
356
  SKYPILOT_NODE_IPS = f'{SKYPILOT_ENV_VAR_PREFIX}NODE_IPS'
@@ -424,6 +429,7 @@ ENV_VAR_DB_CONNECTION_URI = (f'{SKYPILOT_ENV_VAR_PREFIX}DB_CONNECTION_URI')
424
429
  # authentication is enabled in the API server.
425
430
  ENV_VAR_ENABLE_BASIC_AUTH = 'ENABLE_BASIC_AUTH'
426
431
  SKYPILOT_INITIAL_BASIC_AUTH = 'SKYPILOT_INITIAL_BASIC_AUTH'
432
+ ENV_VAR_ENABLE_SERVICE_ACCOUNTS = 'ENABLE_SERVICE_ACCOUNTS'
427
433
 
428
434
  SKYPILOT_DEFAULT_WORKSPACE = 'default'
429
435
 
@@ -475,6 +481,7 @@ MEMORY_SIZE_PATTERN = (
475
481
  ')?$')
476
482
 
477
483
  LAST_USE_TRUNC_LENGTH = 25
484
+ USED_BY_TRUNC_LENGTH = 25
478
485
 
479
486
  MIN_PRIORITY = -1000
480
487
  MAX_PRIORITY = 1000
sky/skypilot_config.py CHANGED
@@ -369,6 +369,34 @@ def get_nested(keys: Tuple[str, ...],
369
369
  disallowed_override_keys=None)
370
370
 
371
371
 
372
+ def get_effective_region_config(
373
+ cloud: str,
374
+ keys: Tuple[str, ...],
375
+ region: Optional[str] = None,
376
+ default_value: Optional[Any] = None,
377
+ override_configs: Optional[Dict[str, Any]] = None) -> Any:
378
+ """Returns the nested key value by reading from config
379
+ Order to get the property_name value:
380
+ 1. if region is specified,
381
+ try to get the value from <cloud>/<region_key>/<region>/keys
382
+ 2. if no region or no override,
383
+ try to get it at the cloud level <cloud>/keys
384
+ 3. if not found at cloud level,
385
+ return either default_value if specified or None
386
+
387
+ Note: This function currently only supports getting region-specific
388
+ config from "kubernetes" cloud. For other clouds, this function behaves
389
+ identically to get_nested().
390
+ """
391
+ return config_utils.get_cloud_config_value_from_dict(
392
+ dict_config=_get_loaded_config(),
393
+ cloud=cloud,
394
+ keys=keys,
395
+ region=region,
396
+ default_value=default_value,
397
+ override_configs=override_configs)
398
+
399
+
372
400
  def get_workspace_cloud(cloud: str,
373
401
  workspace: Optional[str] = None) -> config_utils.Config:
374
402
  """Returns the workspace config."""
@@ -477,10 +505,10 @@ def overlay_skypilot_config(
477
505
  def safe_reload_config() -> None:
478
506
  """Reloads the config, safe to be called concurrently."""
479
507
  with filelock.FileLock(get_skypilot_config_lock_path()):
480
- _reload_config()
508
+ reload_config()
481
509
 
482
510
 
483
- def _reload_config() -> None:
511
+ def reload_config() -> None:
484
512
  internal_config_path = os.environ.get(ENV_VAR_SKYPILOT_CONFIG)
485
513
  if internal_config_path is not None:
486
514
  # {ENV_VAR_SKYPILOT_CONFIG} is used internally.
@@ -641,7 +669,7 @@ def loaded_config_path_serialized() -> Optional[str]:
641
669
 
642
670
 
643
671
  # Load on import, synchronization is guaranteed by python interpreter.
644
- _reload_config()
672
+ reload_config()
645
673
 
646
674
 
647
675
  def loaded() -> bool:
@@ -864,4 +892,4 @@ def update_api_server_config_no_lock(config: config_utils.Config) -> None:
864
892
  config_map_utils.patch_configmap_with_config(
865
893
  config, global_config_path)
866
894
 
867
- _reload_config()
895
+ reload_config()
sky/task.py CHANGED
@@ -884,6 +884,18 @@ class Task:
884
884
  def volumes(self) -> Dict[str, str]:
885
885
  return self._volumes
886
886
 
887
+ def set_volumes(self, volumes: Dict[str, str]) -> None:
888
+ """Sets the volumes for this task.
889
+
890
+ Args:
891
+ volumes: a dict of ``{mount_path: volume_name}``.
892
+ """
893
+ self._volumes = volumes
894
+
895
+ def update_volumes(self, volumes: Dict[str, str]) -> None:
896
+ """Updates the volumes for this task."""
897
+ self._volumes.update(volumes)
898
+
887
899
  def update_envs(
888
900
  self, envs: Union[None, List[Tuple[str, str]],
889
901
  Dict[str, str]]) -> 'Task':
sky/users/permission.py CHANGED
@@ -265,6 +265,35 @@ class PermissionService:
265
265
  f'workspace={workspace_name}, result={result}')
266
266
  return result
267
267
 
268
+ def check_service_account_token_permission(self, user_id: str,
269
+ token_owner_id: str,
270
+ action: str) -> bool:
271
+ """Check service account token permission.
272
+
273
+ This method checks if a user has permission to perform an action on
274
+ a service account token owned by another user.
275
+
276
+ Args:
277
+ user_id: The ID of the user requesting the action
278
+ token_owner_id: The ID of the user who owns the token
279
+ action: The action being performed (e.g., 'delete', 'view')
280
+
281
+ Returns:
282
+ True if the user has permission, False otherwise
283
+ """
284
+ del action
285
+ # Users can always manage their own tokens
286
+ if user_id == token_owner_id:
287
+ return True
288
+
289
+ # Check if user has admin role (admins can manage any token)
290
+ user_roles = self.get_user_roles(user_id)
291
+ if rbac.RoleName.ADMIN.value in user_roles:
292
+ return True
293
+
294
+ # Regular users cannot manage tokens owned by others
295
+ return False
296
+
268
297
  def add_workspace_policy(self, workspace_name: str,
269
298
  users: List[str]) -> None:
270
299
  """Add workspace policy.
sky/users/server.py CHANGED
@@ -3,6 +3,9 @@
3
3
  import contextlib
4
4
  import hashlib
5
5
  import os
6
+ import re
7
+ import secrets
8
+ import time
6
9
  from typing import Any, Dict, Generator, List
7
10
 
8
11
  import fastapi
@@ -16,8 +19,10 @@ from sky.server.requests import payloads
16
19
  from sky.skylet import constants
17
20
  from sky.users import permission
18
21
  from sky.users import rbac
22
+ from sky.users import token_service
19
23
  from sky.utils import common
20
24
  from sky.utils import common_utils
25
+ from sky.utils import resource_checker
21
26
 
22
27
  logger = sky_logging.init_logger(__name__)
23
28
 
@@ -34,10 +39,15 @@ async def users() -> List[Dict[str, Any]]:
34
39
  all_users = []
35
40
  user_list = global_user_state.get_all_users()
36
41
  for user in user_list:
42
+ # Filter out service accounts - they have IDs starting with "sa-"
43
+ if user.is_service_account():
44
+ continue
45
+
37
46
  user_roles = permission.permission_service.get_user_roles(user.id)
38
47
  all_users.append({
39
48
  'id': user.id,
40
49
  'name': user.name,
50
+ 'created_at': user.created_at,
41
51
  'role': user_roles[0] if user_roles else ''
42
52
  })
43
53
  return all_users
@@ -146,10 +156,8 @@ async def user_update(request: fastapi.Request,
146
156
  permission.permission_service.update_role(user_info.id, role)
147
157
 
148
158
 
149
- @router.post('/delete')
150
- async def user_delete(user_delete_body: payloads.UserDeleteBody) -> None:
151
- user_id = user_delete_body.user_id
152
-
159
+ def _delete_user(user_id: str) -> None:
160
+ """Delete a user."""
153
161
  user_info = global_user_state.get_user(user_id)
154
162
  if user_info is None:
155
163
  raise fastapi.HTTPException(status_code=400,
@@ -159,11 +167,25 @@ async def user_delete(user_delete_body: payloads.UserDeleteBody) -> None:
159
167
  raise fastapi.HTTPException(status_code=400,
160
168
  detail=f'Cannot delete internal '
161
169
  f'API server user {user_info.name}')
170
+
171
+ # Check for active clusters and managed jobs owned by the user
172
+ try:
173
+ resource_checker.check_no_active_resources_for_users([(user_id,
174
+ 'delete')])
175
+ except ValueError as e:
176
+ raise fastapi.HTTPException(status_code=400, detail=str(e))
177
+
162
178
  with _user_lock(user_id):
163
179
  global_user_state.delete_user(user_id)
164
180
  permission.permission_service.delete_user(user_id)
165
181
 
166
182
 
183
+ @router.post('/delete')
184
+ async def user_delete(user_delete_body: payloads.UserDeleteBody) -> None:
185
+ user_id = user_delete_body.user_id
186
+ _delete_user(user_id)
187
+
188
+
167
189
  @router.post('/import')
168
190
  async def user_import(
169
191
  user_import_body: payloads.UserImportBody) -> Dict[str, Any]:
@@ -292,7 +314,12 @@ async def user_export() -> Dict[str, Any]:
292
314
  # Create CSV content
293
315
  csv_lines = ['username,password,role'] # Header
294
316
 
317
+ exported_users = []
295
318
  for user in user_list:
319
+ # Filter out service accounts - they have IDs starting with "sa-"
320
+ if user.is_service_account():
321
+ continue
322
+
296
323
  # Get user role
297
324
  user_roles = permission.permission_service.get_user_roles(user.id)
298
325
  role = user_roles[0] if user_roles else rbac.get_default_role()
@@ -307,10 +334,11 @@ async def user_export() -> Dict[str, Any]:
307
334
  if role:
308
335
  line += role
309
336
  csv_lines.append(line)
337
+ exported_users.append(user)
310
338
 
311
339
  csv_content = '\n'.join(csv_lines)
312
340
 
313
- return {'csv_content': csv_content, 'user_count': len(user_list)}
341
+ return {'csv_content': csv_content, 'user_count': len(exported_users)}
314
342
 
315
343
  except Exception as e:
316
344
  raise fastapi.HTTPException(status_code=500,
@@ -330,3 +358,354 @@ def _user_lock(user_id: str) -> Generator[None, None, None]:
330
358
  f'{USER_LOCK_PATH.format(user_id=user_id)}. '
331
359
  'Please try again or manually remove the lock '
332
360
  f'file if you believe it is stale.') from e
361
+
362
+
363
+ # ===============================
364
+ # Service account tokens
365
+ # ===============================
366
+ # SkyPilot currently does not distinguish between service accounts and service
367
+ # account tokens, i.e. service accounts have a 1-1 mapping to service account
368
+ # tokens.
369
+
370
+
371
+ @router.get('/service-account-tokens')
372
+ async def get_service_account_tokens(
373
+ request: fastapi.Request) -> List[Dict[str, Any]]:
374
+ """Get service account tokens. All users can see all tokens."""
375
+ auth_user = request.state.auth_user
376
+ if auth_user is None:
377
+ raise fastapi.HTTPException(status_code=401,
378
+ detail='Authentication required')
379
+
380
+ # All authenticated users can see all tokens
381
+ tokens = global_user_state.get_all_service_account_tokens()
382
+
383
+ result = []
384
+ for token in tokens:
385
+ token_info = {
386
+ 'token_id': token['token_id'],
387
+ 'token_name': token['token_name'],
388
+ 'created_at': token['created_at'],
389
+ 'last_used_at': token['last_used_at'],
390
+ 'expires_at': token['expires_at'],
391
+ 'creator_user_hash': token['creator_user_hash'],
392
+ 'service_account_user_id': token['service_account_user_id'],
393
+ }
394
+
395
+ # Add creator display name
396
+ creator_user = global_user_state.get_user(token['creator_user_hash'])
397
+ token_info[
398
+ 'creator_name'] = creator_user.name if creator_user else 'Unknown'
399
+
400
+ # Add service account name
401
+ sa_user = global_user_state.get_user(token['service_account_user_id'])
402
+ token_info['service_account_name'] = (sa_user.name if sa_user else
403
+ token['token_name'])
404
+
405
+ # Add service account roles
406
+ roles = permission.permission_service.get_user_roles(
407
+ token['service_account_user_id'])
408
+ token_info['service_account_roles'] = roles
409
+
410
+ result.append(token_info)
411
+
412
+ return result
413
+
414
+
415
+ def _generate_service_account_user_id() -> str:
416
+ """Generate a unique user ID for a service account."""
417
+ random_suffix = secrets.token_hex(16) # 16 character hex string
418
+ service_account_id = (f'sa-{random_suffix}')
419
+ return service_account_id
420
+
421
+
422
+ @router.post('/service-account-tokens')
423
+ async def create_service_account_token(
424
+ request: fastapi.Request,
425
+ token_body: payloads.ServiceAccountTokenCreateBody) -> Dict[str, Any]:
426
+ """Create a new service account token."""
427
+ auth_user = request.state.auth_user
428
+ if auth_user is None:
429
+ raise fastapi.HTTPException(status_code=401,
430
+ detail='Authentication required')
431
+
432
+ token_name = token_body.token_name.strip()
433
+
434
+ # Check if token follows a valid format
435
+ if not re.match(constants.CLUSTER_NAME_VALID_REGEX, token_name):
436
+ raise fastapi.HTTPException(
437
+ status_code=400,
438
+ detail='Token name must contain only letters, numbers, and '
439
+ 'underscores. Please use a different name.')
440
+
441
+ # Validate expiration (allow 0 as special value for "never expire")
442
+ if (token_body.expires_in_days is not None and
443
+ token_body.expires_in_days < 0):
444
+ raise fastapi.HTTPException(
445
+ status_code=400,
446
+ detail='Expiration days must be positive or 0 for never expire')
447
+
448
+ try:
449
+ # Generate a unique service account user ID
450
+ service_account_user_id = _generate_service_account_user_id()
451
+
452
+ # Create a user entry for the service account
453
+ service_account_user = models.User(id=service_account_user_id,
454
+ name=token_name)
455
+ is_new_user = global_user_state.add_or_update_user(
456
+ service_account_user, allow_duplicate_name=False)
457
+
458
+ if not is_new_user:
459
+ raise fastapi.HTTPException(
460
+ status_code=400,
461
+ detail=f'Service account with name {token_name!r} '
462
+ f'already exists ({service_account_user_id}). '
463
+ 'Please use a different name.')
464
+
465
+ # Add service account to permission system with default role
466
+ # Import here to avoid circular imports
467
+ # pylint: disable=import-outside-toplevel
468
+ from sky.users.permission import permission_service
469
+ permission_service.add_user_if_not_exists(service_account_user_id)
470
+
471
+ # Handle expiration: 0 means "never expire"
472
+ expires_in_days = token_body.expires_in_days
473
+ if expires_in_days == 0:
474
+ expires_in_days = None
475
+
476
+ # Create JWT-based token with service account user ID
477
+ token_data = token_service.token_service.create_token(
478
+ creator_user_id=auth_user.id,
479
+ service_account_user_id=service_account_user_id,
480
+ token_name=token_name,
481
+ expires_in_days=expires_in_days)
482
+
483
+ # Store token metadata in database
484
+ global_user_state.add_service_account_token(
485
+ token_id=token_data['token_id'],
486
+ token_name=token_name,
487
+ token_hash=token_data['token_hash'],
488
+ creator_user_hash=auth_user.id,
489
+ service_account_user_id=service_account_user_id,
490
+ expires_at=token_data['expires_at'])
491
+
492
+ # Return the JWT token only once (never stored in plain text)
493
+ return {
494
+ 'token_id': token_data['token_id'],
495
+ 'token_name': token_name,
496
+ 'token': token_data['token'], # Full JWT token with sky_ prefix
497
+ 'expires_at': token_data['expires_at'],
498
+ 'service_account_user_id': service_account_user_id,
499
+ 'creator_user_id': auth_user.id,
500
+ 'message': 'Please save this token - it will not be shown again!'
501
+ }
502
+
503
+ except Exception as e: # pylint: disable=broad-except
504
+ logger.error(f'Failed to create service account token: {e}')
505
+ raise fastapi.HTTPException(
506
+ status_code=500,
507
+ detail=f'Failed to create service account token: {e}')
508
+
509
+
510
+ @router.post('/service-account-tokens/delete')
511
+ async def delete_service_account_token(
512
+ request: fastapi.Request,
513
+ token_body: payloads.ServiceAccountTokenDeleteBody) -> Dict[str, str]:
514
+ """Delete a service account token.
515
+
516
+ Admins can delete any token, users can only delete their own.
517
+ """
518
+ auth_user = request.state.auth_user
519
+ if auth_user is None:
520
+ raise fastapi.HTTPException(status_code=401,
521
+ detail='Authentication required')
522
+
523
+ # Get token info first
524
+ token_info = global_user_state.get_service_account_token(
525
+ token_body.token_id)
526
+ if token_info is None:
527
+ raise fastapi.HTTPException(status_code=404, detail='Token not found')
528
+
529
+ # Check permissions using Casbin policy system
530
+ if not permission.permission_service.check_service_account_token_permission(
531
+ auth_user.id, token_info['creator_user_hash'], 'delete'):
532
+ raise fastapi.HTTPException(
533
+ status_code=403,
534
+ detail='You can only delete your own tokens. Only admins can '
535
+ 'delete tokens owned by other users.')
536
+
537
+ # Try to delete the service account user first to make sure there is no
538
+ # active resources owned by the service account.
539
+ service_account_user_id = token_info['service_account_user_id']
540
+ _delete_user(service_account_user_id)
541
+
542
+ # Delete the token
543
+ deleted = global_user_state.delete_service_account_token(
544
+ token_body.token_id)
545
+ if not deleted:
546
+ raise fastapi.HTTPException(status_code=404, detail='Token not found')
547
+
548
+ return {'message': 'Token deleted successfully'}
549
+
550
+
551
+ @router.post('/service-account-tokens/get-role')
552
+ async def get_service_account_role(
553
+ request: fastapi.Request,
554
+ role_body: payloads.ServiceAccountTokenRoleBody) -> Dict[str, Any]:
555
+ """Get the role of a service account."""
556
+ auth_user = request.state.auth_user
557
+ if auth_user is None:
558
+ raise fastapi.HTTPException(status_code=401,
559
+ detail='Authentication required')
560
+
561
+ # Get token info to find the service account user ID
562
+ token_info = global_user_state.get_service_account_token(role_body.token_id)
563
+ if token_info is None:
564
+ raise fastapi.HTTPException(status_code=404, detail='Token not found')
565
+
566
+ # Check permissions - only creator or admin can view roles
567
+ if not permission.permission_service.check_service_account_token_permission(
568
+ auth_user.id, token_info['creator_user_hash'], 'view'):
569
+ raise fastapi.HTTPException(
570
+ status_code=403,
571
+ detail='You can only view roles for your own service accounts. '
572
+ 'Only admins can view roles for service accounts owned by other '
573
+ 'users.')
574
+
575
+ # Get service account roles
576
+ service_account_user_id = token_info['service_account_user_id']
577
+ roles = permission.permission_service.get_user_roles(
578
+ service_account_user_id)
579
+
580
+ return {
581
+ 'token_id': role_body.token_id,
582
+ 'service_account_user_id': service_account_user_id,
583
+ 'roles': roles
584
+ }
585
+
586
+
587
+ @router.post('/service-account-tokens/update-role')
588
+ async def update_service_account_role(
589
+ request: fastapi.Request,
590
+ role_body: payloads.ServiceAccountTokenUpdateRoleBody
591
+ ) -> Dict[str, str]:
592
+ """Update the role of a service account."""
593
+ auth_user = request.state.auth_user
594
+ if auth_user is None:
595
+ raise fastapi.HTTPException(status_code=401,
596
+ detail='Authentication required')
597
+
598
+ # Get token info to find the service account user ID
599
+ token_info = global_user_state.get_service_account_token(role_body.token_id)
600
+ if token_info is None:
601
+ raise fastapi.HTTPException(status_code=404, detail='Token not found')
602
+
603
+ # Check permissions - only creator or admin can update roles
604
+ if not permission.permission_service.check_service_account_token_permission(
605
+ auth_user.id, token_info['creator_user_hash'], 'update'):
606
+ raise fastapi.HTTPException(
607
+ status_code=403,
608
+ detail='You can only update roles for your own service accounts. '
609
+ 'Only admins can update roles for service accounts owned by other '
610
+ 'users.')
611
+
612
+ try:
613
+ # Update service account role
614
+ service_account_user_id = token_info['service_account_user_id']
615
+ permission.permission_service.update_role(service_account_user_id,
616
+ role_body.role)
617
+
618
+ return {
619
+ 'message': f'Service account role updated to {role_body.role}',
620
+ 'token_id': role_body.token_id,
621
+ 'service_account_user_id': service_account_user_id,
622
+ 'new_role': role_body.role
623
+ }
624
+ except Exception as e: # pylint: disable=broad-except
625
+ logger.error(f'Failed to update service account role: {e}')
626
+ raise fastapi.HTTPException(
627
+ status_code=500, detail='Failed to update service account role')
628
+
629
+
630
+ @router.post('/service-account-tokens/rotate')
631
+ async def rotate_service_account_token(
632
+ request: fastapi.Request,
633
+ token_body: payloads.ServiceAccountTokenRotateBody) -> Dict[str, Any]:
634
+ """Rotate a service account token.
635
+
636
+ Generates a new token value for an existing service account while keeping
637
+ the same service account identity and roles.
638
+ """
639
+ auth_user = request.state.auth_user
640
+ if auth_user is None:
641
+ raise fastapi.HTTPException(status_code=401,
642
+ detail='Authentication required')
643
+
644
+ # Get token info
645
+ token_info = global_user_state.get_service_account_token(
646
+ token_body.token_id)
647
+ if token_info is None:
648
+ raise fastapi.HTTPException(status_code=404, detail='Token not found')
649
+
650
+ # Check permissions - same as delete permission (only creator or admin)
651
+ if not permission.permission_service.check_service_account_token_permission(
652
+ auth_user.id, token_info['creator_user_hash'], 'delete'):
653
+ raise fastapi.HTTPException(
654
+ status_code=403,
655
+ detail='You can only rotate your own tokens. Only admins can '
656
+ 'rotate tokens owned by other users.')
657
+
658
+ # Validate expiration if provided (allow 0 as special value for "never
659
+ # expire")
660
+ if (token_body.expires_in_days is not None and
661
+ token_body.expires_in_days < 0):
662
+ raise fastapi.HTTPException(
663
+ status_code=400,
664
+ detail='Expiration days must be positive or 0 for never expire')
665
+
666
+ try:
667
+ # Use provided expiration or preserve original expiration logic
668
+ expires_in_days = token_body.expires_in_days
669
+ if expires_in_days == 0:
670
+ # Special value 0 means "never expire"
671
+ expires_in_days = None
672
+ elif expires_in_days is None:
673
+ # No expiration specified, try to preserve original expiration
674
+ if token_info['expires_at']:
675
+ current_time = time.time()
676
+ remaining_seconds = token_info['expires_at'] - current_time
677
+ if remaining_seconds > 0:
678
+ expires_in_days = max(1,
679
+ int(remaining_seconds / (24 * 3600)))
680
+ else:
681
+ # Token already expired, default to 30 days
682
+ expires_in_days = 30
683
+
684
+ # Generate new JWT token with same service account user ID
685
+ token_data = token_service.token_service.create_token(
686
+ creator_user_id=token_info['creator_user_hash'],
687
+ service_account_user_id=token_info['service_account_user_id'],
688
+ token_name=token_info['token_name'],
689
+ expires_in_days=expires_in_days)
690
+
691
+ # Update token in database with new token hash
692
+ global_user_state.rotate_service_account_token(
693
+ token_id=token_body.token_id,
694
+ new_token_hash=token_data['token_hash'],
695
+ new_expires_at=token_data['expires_at'])
696
+
697
+ # Return the new JWT token only once (never stored in plain text)
698
+ return {
699
+ 'token_id': token_body.token_id,
700
+ 'token_name': token_info['token_name'],
701
+ 'token': token_data['token'], # Full JWT token with sky_ prefix
702
+ 'expires_at': token_data['expires_at'],
703
+ 'service_account_user_id': token_info['service_account_user_id'],
704
+ 'message': ('Token rotated successfully! Please save this new '
705
+ 'token - it will not be shown again!')
706
+ }
707
+
708
+ except Exception as e: # pylint: disable=broad-except
709
+ logger.error(f'Failed to rotate service account token: {e}')
710
+ raise fastapi.HTTPException(
711
+ status_code=500, detail='Failed to rotate service account token')