Skip to content

Commit a178088

Browse files
[Nebius] Support tags #3156 (#3158)
1 parent 6507ac4 commit a178088

4 files changed

Lines changed: 82 additions & 3 deletions

File tree

src/dstack/_internal/core/backends/nebius/compute.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
ComputeWithPrivilegedSupport,
2020
generate_unique_instance_name,
2121
get_user_data,
22+
merge_tags,
2223
)
2324
from dstack._internal.core.backends.base.offers import get_catalog_offers, get_offers_disk_modifier
2425
from dstack._internal.core.backends.nebius import resources
@@ -150,6 +151,18 @@ def create_instance(
150151
if backend_data.cluster is not None:
151152
cluster_id = backend_data.cluster.id
152153

154+
labels = {
155+
"owner": "dstack",
156+
"dstack_project": instance_config.project_name.lower(),
157+
"dstack_name": instance_config.instance_name,
158+
"dstack_user": instance_config.user.lower(),
159+
}
160+
labels = merge_tags(
161+
base_tags=labels,
162+
backend_tags=self.config.tags,
163+
resource_tags=instance_config.tags,
164+
)
165+
labels = resources.filter_invalid_labels(labels)
153166
gpus = instance_offer.instance.resources.gpus
154167
create_disk_op = resources.create_disk(
155168
sdk=self._sdk,
@@ -159,6 +172,7 @@ def create_instance(
159172
image_family="ubuntu24.04-cuda12"
160173
if gpus and gpus[0].name == "B200"
161174
else "ubuntu22.04-cuda12",
175+
labels=labels,
162176
)
163177
create_instance_op = None
164178
try:
@@ -184,6 +198,7 @@ def create_instance(
184198
disk_id=create_disk_op.resource_id,
185199
subnet_id=self._get_subnet_id(instance_offer.region),
186200
preemptible=instance_offer.instance.resources.spot,
201+
labels=labels,
187202
)
188203
_wait_for_instance(self._sdk, create_instance_op)
189204
except BaseException:

src/dstack/_internal/core/backends/nebius/configurator.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from nebius.aio.service_error import RequestError
44

55
from dstack._internal.core.backends.base.configurator import (
6+
TAGS_MAX_NUM,
67
BackendRecord,
78
Configurator,
89
raise_invalid_credentials_error,
@@ -18,6 +19,7 @@
1819
NebiusServiceAccountCreds,
1920
NebiusStoredConfig,
2021
)
22+
from dstack._internal.core.errors import BackendError, ServerClientError
2123
from dstack._internal.core.models.backends.base import BackendType
2224

2325

@@ -53,6 +55,19 @@ def validate_config(self, config: NebiusBackendConfigWithCreds, default_creds_en
5355
f" some of the valid options: {sorted(valid_fabrics)}"
5456
),
5557
)
58+
self._check_config_tags(config)
59+
60+
def _check_config_tags(self, config: NebiusBackendConfigWithCreds):
61+
if not config.tags:
62+
return
63+
if len(config.tags) > TAGS_MAX_NUM:
64+
raise ServerClientError(
65+
f"Maximum number of tags exceeded. Up to {TAGS_MAX_NUM} tags is allowed."
66+
)
67+
try:
68+
resources.validate_labels(config.tags)
69+
except BackendError as e:
70+
raise ServerClientError(e.args[0])
5671

5772
def create_backend(
5873
self, project_name: str, config: NebiusBackendConfigWithCreds

src/dstack/_internal/core/backends/nebius/models.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
22
from pathlib import Path
3-
from typing import Annotated, Literal, Optional, Union
3+
from typing import Annotated, Dict, Literal, Optional, Union
44

55
from pydantic import Field, root_validator
66

@@ -141,6 +141,12 @@ class NebiusBackendConfig(CoreModel):
141141
)
142142
),
143143
] = None
144+
tags: Annotated[
145+
Optional[Dict[str, str]],
146+
Field(
147+
description="The tags (labels) that will be assigned to resources created by `dstack`"
148+
),
149+
] = None
144150

145151

146152
class NebiusBackendConfigWithCreds(NebiusBackendConfig):

src/dstack/_internal/core/backends/nebius/resources.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import logging
2+
import re
23
import time
34
from collections import defaultdict
45
from collections.abc import Container as ContainerT
56
from collections.abc import Generator, Iterable, Sequence
67
from contextlib import contextmanager
78
from tempfile import NamedTemporaryFile
8-
from typing import Optional
9+
from typing import Dict, Optional
910

1011
from nebius.aio.authorization.options import options_to_metadata
1112
from nebius.aio.operation import Operation as SDKOperation
@@ -249,13 +250,14 @@ def get_default_subnet(sdk: SDK, project_id: str) -> Subnet:
249250

250251

251252
def create_disk(
252-
sdk: SDK, name: str, project_id: str, size_mib: int, image_family: str
253+
sdk: SDK, name: str, project_id: str, size_mib: int, image_family: str, labels: Dict[str, str]
253254
) -> SDKOperation[Operation]:
254255
client = DiskServiceClient(sdk)
255256
request = CreateDiskRequest(
256257
metadata=ResourceMetadata(
257258
name=name,
258259
parent_id=project_id,
260+
labels=labels,
259261
),
260262
spec=DiskSpec(
261263
size_mebibytes=size_mib,
@@ -288,12 +290,14 @@ def create_instance(
288290
disk_id: str,
289291
subnet_id: str,
290292
preemptible: bool,
293+
labels: Dict[str, str],
291294
) -> SDKOperation[Operation]:
292295
client = InstanceServiceClient(sdk)
293296
request = CreateInstanceRequest(
294297
metadata=ResourceMetadata(
295298
name=name,
296299
parent_id=project_id,
300+
labels=labels,
297301
),
298302
spec=InstanceSpec(
299303
cloud_init_user_data=user_data,
@@ -367,3 +371,42 @@ def delete_cluster(sdk: SDK, cluster_id: str) -> None:
367371
metadata=REQUEST_MD,
368372
)
369373
)
374+
375+
376+
def filter_invalid_labels(labels: Dict[str, str]) -> Dict[str, str]:
377+
filtered_labels = {}
378+
for k, v in labels.items():
379+
if not _is_valid_label(k, v):
380+
logger.warning("Skipping invalid label '%s: %s'", k, v)
381+
continue
382+
filtered_labels[k] = v
383+
return filtered_labels
384+
385+
386+
def validate_labels(labels: Dict[str, str]):
387+
for k, v in labels.items():
388+
if not _is_valid_label(k, v):
389+
raise BackendError("Invalid resource labels")
390+
391+
392+
def _is_valid_label(key: str, value: str) -> bool:
393+
# TODO: [Nebius] current validation logic reuses GCP's approach.
394+
# There is no public information on Nebius labels restrictions.
395+
return is_valid_resource_name(key) and is_valid_label_value(value)
396+
397+
398+
MAX_RESOURCE_NAME_LEN = 63
399+
NAME_PATTERN = re.compile(r"^[a-z][_\-a-z0-9]{0,62}$")
400+
LABEL_VALUE_PATTERN = re.compile(r"^[_\-a-z0-9]{0,63}$")
401+
402+
403+
def is_valid_resource_name(name: str) -> bool:
404+
if len(name) < 1 or len(name) > MAX_RESOURCE_NAME_LEN:
405+
return False
406+
match = re.match(NAME_PATTERN, name)
407+
return match is not None
408+
409+
410+
def is_valid_label_value(value: str) -> bool:
411+
match = re.match(LABEL_VALUE_PATTERN, value)
412+
return match is not None

0 commit comments

Comments
 (0)