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.
- sky/__init__.py +2 -2
- sky/adaptors/kubernetes.py +14 -0
- sky/adaptors/nebius.py +2 -2
- sky/authentication.py +12 -5
- sky/backends/backend_utils.py +92 -26
- sky/check.py +5 -2
- sky/client/cli/command.py +39 -8
- sky/client/sdk.py +217 -167
- sky/client/service_account_auth.py +47 -0
- sky/clouds/aws.py +10 -4
- sky/clouds/azure.py +5 -2
- sky/clouds/cloud.py +5 -2
- sky/clouds/gcp.py +31 -18
- sky/clouds/kubernetes.py +54 -34
- sky/clouds/nebius.py +8 -2
- sky/clouds/ssh.py +5 -2
- sky/clouds/utils/aws_utils.py +10 -4
- sky/clouds/utils/gcp_utils.py +22 -7
- sky/clouds/utils/oci_utils.py +62 -14
- sky/dashboard/out/404.html +1 -1
- sky/dashboard/out/_next/static/NdypbqMxaYucRGfopkKXa/_buildManifest.js +1 -0
- sky/dashboard/out/_next/static/chunks/1043-1b39779691bb4030.js +1 -0
- sky/dashboard/out/_next/static/chunks/{141-fa5a20cbf401b351.js → 1141-726e5a3f00b67185.js} +2 -2
- sky/dashboard/out/_next/static/chunks/1272-1ef0bf0237faccdb.js +1 -0
- sky/dashboard/out/_next/static/chunks/1664-d65361e92b85e786.js +1 -0
- sky/dashboard/out/_next/static/chunks/1691.44e378727a41f3b5.js +21 -0
- sky/dashboard/out/_next/static/chunks/1871-80dea41717729fa5.js +6 -0
- sky/dashboard/out/_next/static/chunks/2544.27f70672535675ed.js +1 -0
- sky/dashboard/out/_next/static/chunks/{875.52c962183328b3f2.js → 2875.c24c6d57dc82e436.js} +1 -1
- sky/dashboard/out/_next/static/chunks/3256.7257acd01b481bed.js +11 -0
- sky/dashboard/out/_next/static/chunks/3698-52ad1ca228faa776.js +1 -0
- sky/dashboard/out/_next/static/chunks/3785.b3cc2bc1d49d2c3c.js +1 -0
- sky/dashboard/out/_next/static/chunks/3937.d7f1c55d1916c7f2.js +1 -0
- sky/dashboard/out/_next/static/chunks/{947-6620842ef80ae879.js → 3947-b059261d6fa88a1f.js} +1 -1
- sky/dashboard/out/_next/static/chunks/{697.6460bf72e760addd.js → 4697.f5421144224da9fc.js} +1 -1
- sky/dashboard/out/_next/static/chunks/4725.4c849b1e05c8e9ad.js +1 -0
- sky/dashboard/out/_next/static/chunks/5230-df791914b54d91d9.js +1 -0
- sky/dashboard/out/_next/static/chunks/{491.b3d264269613fe09.js → 5491.918ffed0ba7a5294.js} +1 -1
- sky/dashboard/out/_next/static/chunks/5739-5ea3ffa10fc884f2.js +8 -0
- sky/dashboard/out/_next/static/chunks/616-162f3033ffcd3d31.js +39 -0
- sky/dashboard/out/_next/static/chunks/6601-fcfad0ddf92ec7ab.js +1 -0
- sky/dashboard/out/_next/static/chunks/6989-6ff4e45dfb49d11d.js +1 -0
- sky/dashboard/out/_next/static/chunks/6990-d0dc765474fa0eca.js +1 -0
- sky/dashboard/out/_next/static/chunks/8969-909d53833da080cb.js +1 -0
- sky/dashboard/out/_next/static/chunks/8982.a2e214068f30a857.js +1 -0
- sky/dashboard/out/_next/static/chunks/{25.76c246239df93d50.js → 9025.a7c44babfe56ce09.js} +2 -2
- sky/dashboard/out/_next/static/chunks/938-044ad21de8b4626b.js +1 -0
- sky/dashboard/out/_next/static/chunks/9470-21d059a1dfa03f61.js +1 -0
- sky/dashboard/out/_next/static/chunks/9984.739ae958a066298d.js +1 -0
- sky/dashboard/out/_next/static/chunks/fd9d1056-61f2257a9cd8b32b.js +1 -0
- sky/dashboard/out/_next/static/chunks/{framework-87d061ee6ed71b28.js → framework-efc06c2733009cd3.js} +1 -1
- sky/dashboard/out/_next/static/chunks/main-app-68c028b1bc5e1b72.js +1 -0
- sky/dashboard/out/_next/static/chunks/{main-e0e2335212e72357.js → main-c0a4f1ea606d48d2.js} +1 -1
- sky/dashboard/out/_next/static/chunks/pages/{_app-9a3ce3170d2edcec.js → _app-a37b06ddb64521fd.js} +2 -2
- sky/dashboard/out/_next/static/chunks/pages/_error-c72a1f77a3c0be1b.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]/[job]-8135aba0712bda37.js +6 -0
- sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]-b8e1114e6d38218c.js +6 -0
- sky/dashboard/out/_next/static/chunks/pages/clusters-9744c271a1642f76.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/config-a2673b256b6d416f.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/index-927ddeebe57a8ac3.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/infra/[context]-8b0809f59034d509.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/infra-ae9d2f705ce582c9.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/jobs/[job]-c4d5cfac7fbc0668.js +16 -0
- sky/dashboard/out/_next/static/chunks/pages/jobs-5bbdc71878f0a068.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/users-cd43fb3c122eedde.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/volumes-4ebf6484f7216387.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/workspace/new-5629d4e551dba1ee.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/workspaces/[name]-7c0187f43757a548.js +1 -0
- sky/dashboard/out/_next/static/chunks/pages/workspaces-06bde99155fa6292.js +1 -0
- sky/dashboard/out/_next/static/chunks/webpack-d427db53e54de9ce.js +1 -0
- sky/dashboard/out/_next/static/css/0da6afe66176678a.css +3 -0
- sky/dashboard/out/clusters/[cluster]/[job].html +1 -1
- sky/dashboard/out/clusters/[cluster].html +1 -1
- sky/dashboard/out/clusters.html +1 -1
- sky/dashboard/out/config.html +1 -1
- sky/dashboard/out/index.html +1 -1
- sky/dashboard/out/infra/[context].html +1 -1
- sky/dashboard/out/infra.html +1 -1
- sky/dashboard/out/jobs/[job].html +1 -1
- sky/dashboard/out/jobs.html +1 -1
- sky/dashboard/out/users.html +1 -1
- sky/dashboard/out/volumes.html +1 -1
- sky/dashboard/out/workspace/new.html +1 -1
- sky/dashboard/out/workspaces/[name].html +1 -1
- sky/dashboard/out/workspaces.html +1 -1
- sky/data/storage.py +8 -3
- sky/global_user_state.py +257 -9
- sky/jobs/client/sdk.py +20 -25
- sky/models.py +16 -0
- sky/optimizer.py +46 -0
- sky/provision/__init__.py +14 -6
- sky/provision/kubernetes/config.py +1 -1
- sky/provision/kubernetes/constants.py +9 -0
- sky/provision/kubernetes/instance.py +24 -18
- sky/provision/kubernetes/network.py +15 -9
- sky/provision/kubernetes/network_utils.py +42 -23
- sky/provision/kubernetes/utils.py +73 -35
- sky/provision/kubernetes/volume.py +77 -15
- sky/provision/nebius/utils.py +10 -4
- sky/resources.py +10 -4
- sky/serve/client/sdk.py +28 -34
- sky/server/common.py +51 -3
- sky/server/constants.py +3 -0
- sky/server/requests/executor.py +4 -0
- sky/server/requests/payloads.py +33 -0
- sky/server/requests/requests.py +19 -0
- sky/server/rest.py +6 -15
- sky/server/server.py +121 -6
- sky/skylet/constants.py +7 -0
- sky/skypilot_config.py +32 -4
- sky/task.py +12 -0
- sky/users/permission.py +29 -0
- sky/users/server.py +384 -5
- sky/users/token_service.py +196 -0
- sky/utils/common_utils.py +4 -5
- sky/utils/config_utils.py +41 -0
- sky/utils/controller_utils.py +5 -1
- sky/utils/log_utils.py +68 -0
- sky/utils/resource_checker.py +153 -0
- sky/utils/resources_utils.py +12 -4
- sky/utils/schemas.py +87 -60
- sky/utils/subprocess_utils.py +2 -6
- sky/volumes/server/core.py +103 -78
- sky/volumes/utils.py +22 -5
- sky/workspaces/core.py +9 -117
- {skypilot_nightly-1.0.0.dev20250627.dist-info → skypilot_nightly-1.0.0.dev20250630.dist-info}/METADATA +1 -1
- {skypilot_nightly-1.0.0.dev20250627.dist-info → skypilot_nightly-1.0.0.dev20250630.dist-info}/RECORD +133 -128
- sky/dashboard/out/_next/static/HudU4f4Xsy-cP51JvXSZ-/_buildManifest.js +0 -1
- sky/dashboard/out/_next/static/chunks/230-d6e363362017ff3a.js +0 -1
- sky/dashboard/out/_next/static/chunks/43-36177d00f6956ab2.js +0 -1
- sky/dashboard/out/_next/static/chunks/470-92dd1614396389be.js +0 -1
- sky/dashboard/out/_next/static/chunks/544.110e53813fb98e2e.js +0 -1
- sky/dashboard/out/_next/static/chunks/616-d6128fa9e7cae6e6.js +0 -39
- sky/dashboard/out/_next/static/chunks/645.961f08e39b8ce447.js +0 -1
- sky/dashboard/out/_next/static/chunks/664-047bc03493fda379.js +0 -1
- sky/dashboard/out/_next/static/chunks/690.55f9eed3be903f56.js +0 -16
- sky/dashboard/out/_next/static/chunks/785.dc2686c3c1235554.js +0 -1
- sky/dashboard/out/_next/static/chunks/798-c0525dc3f21e488d.js +0 -1
- sky/dashboard/out/_next/static/chunks/799-3625946b2ec2eb30.js +0 -8
- sky/dashboard/out/_next/static/chunks/871-3db673be3ee3750b.js +0 -6
- sky/dashboard/out/_next/static/chunks/937.3759f538f11a0953.js +0 -1
- sky/dashboard/out/_next/static/chunks/938-068520cc11738deb.js +0 -1
- sky/dashboard/out/_next/static/chunks/969-d3a0b53f728d280a.js +0 -1
- sky/dashboard/out/_next/static/chunks/973-81b2d057178adb76.js +0 -1
- sky/dashboard/out/_next/static/chunks/982.1b61658204416b0f.js +0 -1
- sky/dashboard/out/_next/static/chunks/984.e8bac186a24e5178.js +0 -1
- sky/dashboard/out/_next/static/chunks/989-db34c16ad7ea6155.js +0 -1
- sky/dashboard/out/_next/static/chunks/990-0ad5ea1699e03ee8.js +0 -1
- sky/dashboard/out/_next/static/chunks/fd9d1056-2821b0f0cabcd8bd.js +0 -1
- sky/dashboard/out/_next/static/chunks/main-app-241eb28595532291.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/_error-1be831200e60c5c0.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]/[job]-aff040d7bc5d0086.js +0 -6
- sky/dashboard/out/_next/static/chunks/pages/clusters/[cluster]-8040f2483897ed0c.js +0 -6
- sky/dashboard/out/_next/static/chunks/pages/clusters-f119a5630a1efd61.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/config-6b255eae088da6a3.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/index-6b0d9e5031b70c58.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/infra/[context]-b302aea4d65766bf.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/infra-ee8cc4d449945d19.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/jobs/[job]-e4b23128db0774cd.js +0 -16
- sky/dashboard/out/_next/static/chunks/pages/jobs-0a5695ff3075d94a.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/users-4978cbb093e141e7.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/volumes-476b670ef33d1ecd.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/workspace/new-5b59bce9eb208d84.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/workspaces/[name]-cb7e720b739de53a.js +0 -1
- sky/dashboard/out/_next/static/chunks/pages/workspaces-50e230828730cfb3.js +0 -1
- sky/dashboard/out/_next/static/chunks/webpack-08fdb9e6070127fc.js +0 -1
- sky/dashboard/out/_next/static/css/52082cf558ec9705.css +0 -3
- /sky/dashboard/out/_next/static/{HudU4f4Xsy-cP51JvXSZ- → NdypbqMxaYucRGfopkKXa}/_ssgManifest.js +0 -0
- /sky/dashboard/out/_next/static/chunks/{804-4c9fc53aa74bc191.js → 804-9f5e98ce84d46bdd.js} +0 -0
- {skypilot_nightly-1.0.0.dev20250627.dist-info → skypilot_nightly-1.0.0.dev20250630.dist-info}/WHEEL +0 -0
- {skypilot_nightly-1.0.0.dev20250627.dist-info → skypilot_nightly-1.0.0.dev20250630.dist-info}/entry_points.txt +0 -0
- {skypilot_nightly-1.0.0.dev20250627.dist-info → skypilot_nightly-1.0.0.dev20250630.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
508
|
+
reload_config()
|
481
509
|
|
482
510
|
|
483
|
-
def
|
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
|
-
|
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
|
-
|
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
|
-
|
150
|
-
|
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(
|
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')
|