Skip to content

Commit c5d1bd5

Browse files
authored
Support Nebius tenancies with multiple projects (#2575)
As it turns out, Nebius tenancies can have more than one project per region, this feature is just not public yet. Address such tenancies in `dstack`: - Allow configuring projects in the backend config. - When projects are not configured and `dstack` cannot detect the default project, return a backend configuration error, as this most likely means the backend needs additional configuration.
1 parent e570e8d commit c5d1bd5

File tree

6 files changed

+266
-87
lines changed

6 files changed

+266
-87
lines changed

docs/docs/concepts/backends.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,25 @@ projects:
647647

648648
</div>
649649

650+
??? info "Projects"
651+
If you have multiple projects per region, specify which ones to use, at most one per region.
652+
653+
<div editor-title="~/.dstack/server/config.yml">
654+
655+
```yaml
656+
type: nebius
657+
projects:
658+
- project-e00jt6t095t1ahrg4re30
659+
- project-e01iahuh3cklave4ao1nv
660+
creds:
661+
type: service_account
662+
service_account_id: serviceaccount-e00dhnv9ftgb3cqmej
663+
public_key_id: publickey-e00ngaex668htswqy4
664+
private_key_file: ~/path/to/key.pem
665+
```
666+
667+
</div>
668+
650669
!!! info "Python version"
651670
Nebius is only supported if `dstack server` is running on Python 3.10 or higher.
652671

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ def _sdk(self) -> SDK:
8686

8787
@cached_property
8888
def _region_to_project_id(self) -> dict[str, str]:
89-
return resources.get_region_to_project_id_map(self._sdk)
89+
return resources.get_region_to_project_id_map(
90+
self._sdk,
91+
configured_regions=self.config.regions,
92+
configured_project_ids=self.config.projects,
93+
)
9094

9195
def _get_subnet_id(self, region: str) -> str:
9296
if region not in self._subnet_id_cache:
@@ -100,7 +104,7 @@ def get_offers(
100104
) -> List[InstanceOfferWithAvailability]:
101105
offers = get_catalog_offers(
102106
backend=BackendType.NEBIUS,
103-
locations=self.config.regions or list(self._region_to_project_id),
107+
locations=list(self._region_to_project_id),
104108
requirements=requirements,
105109
extra_filter=_supported_instances,
106110
configurable_disk_size=CONFIGURABLE_DISK_SIZE,

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

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,15 @@ def validate_config(self, config: NebiusBackendConfigWithCreds, default_creds_en
2929
assert isinstance(config.creds, NebiusServiceAccountCreds)
3030
try:
3131
sdk = resources.make_sdk(config.creds)
32-
available_regions = set(resources.get_region_to_project_id_map(sdk))
32+
# check that it's possible to build the projects map with configured settings
33+
resources.get_region_to_project_id_map(
34+
sdk, configured_regions=config.regions, configured_project_ids=config.projects
35+
)
3336
except (ValueError, RequestError) as e:
3437
raise_invalid_credentials_error(
3538
fields=[["creds"]],
3639
details=str(e),
3740
)
38-
if invalid_regions := set(config.regions or []) - available_regions:
39-
raise_invalid_credentials_error(
40-
fields=[["regions"]],
41-
details=(
42-
f"Configured regions {invalid_regions} do not exist in this Nebius tenancy."
43-
" Omit `regions` to use all regions or select some of the available regions:"
44-
f" {available_regions}"
45-
),
46-
)
4741

4842
def create_backend(
4943
self, project_name: str, config: NebiusBackendConfigWithCreds

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from dstack._internal.core.backends.base.models import fill_data
66
from dstack._internal.core.models.common import CoreModel
77

8+
DEFAULT_PROJECT_NAME_PREFIX = "default-project"
9+
810

911
class NebiusServiceAccountCreds(CoreModel):
1012
type: Annotated[Literal["service_account"], Field(description="The type of credentials")] = (
@@ -70,9 +72,20 @@ class NebiusBackendConfig(CoreModel):
7072
Literal["nebius"],
7173
Field(description="The type of backend"),
7274
] = "nebius"
75+
projects: Annotated[
76+
Optional[list[str]],
77+
Field(
78+
description=(
79+
"The list of allowed Nebius project IDs."
80+
" Omit to use the default project in each region."
81+
" The project is considered default if it is the only project in the region"
82+
f" or if its name starts with `{DEFAULT_PROJECT_NAME_PREFIX}`"
83+
)
84+
),
85+
] = None
7386
regions: Annotated[
7487
Optional[list[str]],
75-
Field(description="The list of Nebius regions. Omit to use all regions"),
88+
Field(description="The list of allowed Nebius regions. Omit to allow all regions"),
7689
] = None
7790

7891

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

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
import time
33
from collections import defaultdict
44
from collections.abc import Container as ContainerT
5-
from collections.abc import Generator
5+
from collections.abc import Generator, Iterable, Sequence
66
from contextlib import contextmanager
77
from tempfile import NamedTemporaryFile
8+
from typing import Optional
89

910
from nebius.aio.authorization.options import options_to_metadata
1011
from nebius.aio.operation import Operation as SDKOperation
@@ -40,7 +41,11 @@
4041
from nebius.api.nebius.vpc.v1 import ListSubnetsRequest, Subnet, SubnetServiceClient
4142
from nebius.sdk import SDK
4243

43-
from dstack._internal.core.backends.nebius.models import NebiusServiceAccountCreds
44+
from dstack._internal.core.backends.base.configurator import raise_invalid_credentials_error
45+
from dstack._internal.core.backends.nebius.models import (
46+
DEFAULT_PROJECT_NAME_PREFIX,
47+
NebiusServiceAccountCreds,
48+
)
4449
from dstack._internal.core.errors import BackendError, NoCapacityError
4550
from dstack._internal.utils.event_loop import DaemonEventLoop
4651
from dstack._internal.utils.logging import get_logger
@@ -110,7 +115,37 @@ def wait_for_operation(
110115
LOOP.await_(op.update(timeout=REQUEST_TIMEOUT, metadata=REQUEST_MD))
111116

112117

113-
def get_region_to_project_id_map(sdk: SDK) -> dict[str, str]:
118+
def get_region_to_project_id_map(
119+
sdk: SDK, configured_regions: Optional[list[str]], configured_project_ids: Optional[list[str]]
120+
) -> dict[str, str]:
121+
"""Validate backend settings and build region->project_id map"""
122+
123+
projects = list_tenant_projects(sdk)
124+
if configured_regions:
125+
validate_regions(
126+
configured=set(configured_regions), available={p.status.region for p in projects}
127+
)
128+
if configured_project_ids is not None:
129+
return _get_region_to_configured_project_id_map(
130+
projects, configured_project_ids, configured_regions
131+
)
132+
else:
133+
return _get_region_to_default_project_id_map(projects, configured_regions)
134+
135+
136+
def validate_regions(configured: set[str], available: set[str]) -> None:
137+
if invalid := set(configured) - available:
138+
raise_invalid_credentials_error(
139+
fields=[["regions"]],
140+
details=(
141+
f"Configured regions {invalid} do not exist in this Nebius tenancy."
142+
" Omit `regions` to use all regions or select some of the available regions:"
143+
f" {available}"
144+
),
145+
)
146+
147+
148+
def list_tenant_projects(sdk: SDK) -> Sequence[Container]:
114149
tenants = LOOP.await_(
115150
TenantServiceClient(sdk).list(
116151
ListTenantsRequest(), timeout=REQUEST_TIMEOUT, metadata=REQUEST_MD
@@ -126,26 +161,72 @@ def get_region_to_project_id_map(sdk: SDK) -> dict[str, str]:
126161
metadata=REQUEST_MD,
127162
)
128163
)
164+
return projects.items
165+
166+
167+
def _get_region_to_default_project_id_map(
168+
all_tenant_projects: Iterable[Container], configured_regions: Optional[list[str]]
169+
) -> dict[str, str]:
129170
region_to_projects: defaultdict[str, list[Container]] = defaultdict(list)
130-
for project in projects.items:
171+
for project in all_tenant_projects:
131172
region_to_projects[project.status.region].append(project)
132173
region_to_project_id = {}
133174
for region, region_projects in region_to_projects.items():
175+
if configured_regions and region not in configured_regions:
176+
continue
134177
if len(region_projects) != 1:
135-
# Currently, there can only be one project per region.
136-
# This condition is implemented just in case Nebius suddenly allows more projects.
137178
region_projects = [
138-
p for p in region_projects if p.metadata.name.startswith("default-project")
179+
p
180+
for p in region_projects
181+
if p.metadata.name.startswith(DEFAULT_PROJECT_NAME_PREFIX)
139182
]
140183
if len(region_projects) != 1:
141-
logger.warning(
142-
"Could not find the default project in region %s, tenant %s", region, tenant_id
184+
raise_invalid_credentials_error(
185+
["regions"],
186+
(
187+
f"Could not find the default project in region {region}."
188+
" Consider setting the `projects` property in backend settings"
189+
),
143190
)
144-
continue
145191
region_to_project_id[region] = region_projects[0].metadata.id
146192
return region_to_project_id
147193

148194

195+
def _get_region_to_configured_project_id_map(
196+
all_tenant_projects: Iterable[Container],
197+
configured_project_ids: list[str],
198+
configured_regions: Optional[list[str]],
199+
) -> dict[str, str]:
200+
project_id_to_project = {p.metadata.id: p for p in all_tenant_projects}
201+
region_to_project_id = {}
202+
for project_id in configured_project_ids:
203+
project = project_id_to_project.get(project_id)
204+
if project is None:
205+
raise_invalid_credentials_error(
206+
["projects"],
207+
f"Configured project ID {project_id!r} not found in this Nebius tenancy",
208+
)
209+
duplicate_project_id = region_to_project_id.get(project.status.region)
210+
if duplicate_project_id:
211+
raise_invalid_credentials_error(
212+
["projects"],
213+
(
214+
f"Configured projects {project_id} and {duplicate_project_id}"
215+
f" both belong to the same region {project.status.region}."
216+
" Only one project per region is allowed"
217+
),
218+
)
219+
region_to_project_id[project.status.region] = project_id
220+
if configured_regions:
221+
# only filter by region after validating all project IDs
222+
return {
223+
region: project_id
224+
for region, project_id in region_to_project_id.items()
225+
if region in configured_regions
226+
}
227+
return region_to_project_id
228+
229+
149230
def get_default_subnet(sdk: SDK, project_id: str) -> Subnet:
150231
subnets = LOOP.await_(
151232
SubnetServiceClient(sdk).list(

0 commit comments

Comments
 (0)