Skip to content

Commit 41a1e95

Browse files
[Feature]: Nebius switch to using nebius iam auth-public-key generate #2934
1 parent 441f721 commit 41a1e95

3 files changed

Lines changed: 211 additions & 7 deletions

File tree

docs/docs/concepts/backends.md

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -452,14 +452,43 @@ There are two ways to configure GCP: using a service account or using the defaul
452452
- name: main
453453
backends:
454454
- type: gcp
455-
project_id: gcp-project-id
455+
project_id: my-gcp-project
456456
creds:
457457
type: service_account
458458
filename: ~/.dstack/server/gcp-024ed630eab5.json
459459
```
460460

461461
</div>
462462

463+
??? info "User interface"
464+
If you are configuring the `gcp` backend on the [project settigns page](projects.md#backends),
465+
specify the contents of the JSON file in `data`:
466+
467+
<div editor-title="~/.dstack/server/config.yml">
468+
469+
```yaml
470+
type: gcp
471+
project_id: my-gcp-project
472+
creds:
473+
type: service_account
474+
data: |
475+
{
476+
"type": "service_account",
477+
"project_id": "my-gcp-project",
478+
"private_key_id": "abcd1234efgh5678ijkl9012mnop3456qrst7890",
479+
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEv...rest_of_key...IDAQAB\n-----END PRIVATE KEY-----\n",
480+
"client_email": "my-service-account@my-gcp-project.iam.gserviceaccount.com",
481+
"client_id": "123456789012345678901",
482+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
483+
"token_uri": "https://oauth2.googleapis.com/token",
484+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
485+
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/my-service-account%40my-gcp-project.iam.gserviceaccount.com",
486+
"universe_domain": "googleapis.com"
487+
}
488+
```
489+
490+
</div>
491+
463492
If you don't know your GCP project ID, use [Google Cloud CLI :material-arrow-top-right-thin:{ .external }](https://cloud.google.com/sdk/docs/install-sdk):
464493

465494
```shell
@@ -638,8 +667,33 @@ projects:
638667
639668
</div>
640669
641-
??? info "Configuring in the UI"
642-
If you are configuring the backend in the `dstack` UI, specify the contents of the private key file in `private_key_content`.
670+
??? info "Credentials file"
671+
It's also possible to configure the `nebius` backend using a credentials file [generated :material-arrow-top-right-thin:{ .external }](https://docs.nebius.com/iam/service-accounts/authorized-keys#create){:target="_blank"} by the `nebius` CLI:
672+
673+
<div class="termy">
674+
675+
```shell
676+
$ nebius iam auth-public-key generate \
677+
--service-account-id <service account ID> \
678+
--output ~/.nebius/sa-credentials.json
679+
```
680+
681+
</div>
682+
683+
684+
```yaml
685+
projects:
686+
- name: main
687+
backends:
688+
- type: nebius
689+
creds:
690+
type: service_account
691+
filename: ~/.nebius/sa-credentials.json
692+
```
693+
694+
??? info "User interface"
695+
If you are configuring the `nebius` backend on the [project settigns page](projects.md#backends),
696+
specify the contents of the private key file in `private_key_content`:
643697

644698
<div editor-title="~/.dstack/server/config.yml">
645699

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

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import json
2+
from pathlib import Path
13
from typing import Annotated, Literal, Optional, Union
24

5+
from nebius.base.service_account.credentials_file import (
6+
ServiceAccountCredentials,
7+
)
38
from pydantic import Field, root_validator
49

510
from dstack._internal.core.backends.base.models import fill_data
@@ -27,30 +32,70 @@ class NebiusServiceAccountCreds(CoreModel):
2732
)
2833
),
2934
]
35+
filename: Annotated[
36+
Optional[str], Field(description="The path to the service account credentials file")
37+
] = None
3038

3139

3240
class NebiusServiceAccountFileCreds(CoreModel):
3341
type: Annotated[Literal["service_account"], Field(description="The type of credentials")] = (
3442
"service_account"
3543
)
36-
service_account_id: Annotated[str, Field(description="Service account ID")]
37-
public_key_id: Annotated[str, Field(description="ID of the service account public key")]
44+
service_account_id: Annotated[
45+
Optional[str],
46+
Field(
47+
description=(
48+
"Service account ID. Set automatically if `filename` is specified. When configuring via the UI, it must be specified explicitly"
49+
)
50+
),
51+
] = None
52+
public_key_id: Annotated[
53+
Optional[str],
54+
Field(
55+
description=(
56+
"ID of the service account public key. Set automatically if `filename` is specified. When configuring via the UI, it must be specified explicitly"
57+
)
58+
),
59+
] = None
3860
private_key_file: Annotated[
39-
Optional[str], Field(description=("Path to the service account private key"))
61+
Optional[str],
62+
Field(
63+
description=(
64+
"Path to the service account private key. Set automatically if `filename` or `private_key_content` is specified. When configuring via the UI, it must be specified explicitly"
65+
)
66+
),
4067
] = None
4168
private_key_content: Annotated[
4269
Optional[str],
4370
Field(
4471
description=(
4572
"Content of the service account private key. When configuring via"
4673
" `server/config.yml`, it's automatically filled from `private_key_file`."
47-
" When configuring via UI, it has to be specified explicitly."
74+
" When configuring via UI, it has to be specified explicitly"
4875
)
4976
),
5077
] = None
78+
filename: Annotated[
79+
Optional[str], Field(description="The path to the service account credentials file")
80+
] = None
5181

5282
@root_validator
5383
def fill_data(cls, values):
84+
if filename := values.get("filename"):
85+
try:
86+
with open(Path(filename).expanduser()) as f:
87+
data = json.load(f)
88+
credentials = ServiceAccountCredentials.from_json(data)
89+
subject = credentials.subject_credentials
90+
values["service_account_id"] = subject.sub
91+
values["public_key_id"] = subject.kid
92+
values["private_key_content"] = subject.private_key
93+
except OSError:
94+
raise ValueError(f"No such file {filename}")
95+
except Exception as e:
96+
raise ValueError(f"Failed to parse credentials file {filename}: {e}")
97+
return values
98+
5499
return fill_data(
55100
values, filename_field="private_key_file", data_field="private_key_content"
56101
)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import json
2+
import sys
3+
from pathlib import Path
4+
from unittest.mock import patch
5+
6+
import pytest
7+
import yaml
8+
9+
from dstack._internal.server import settings
10+
from dstack._internal.server.services.config import (
11+
ServerConfigManager,
12+
file_config_to_config,
13+
)
14+
15+
16+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="Nebius requires Python 3.10")
17+
class TestNebiusBackendConfig:
18+
def test_with_filename(self, tmp_path: Path):
19+
creds_json = {
20+
"subject-credentials": {
21+
"type": "JWT",
22+
"alg": "RS256",
23+
"private-key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n",
24+
"kid": "publickey-e00test",
25+
"iss": "serviceaccount-e00test",
26+
"sub": "serviceaccount-e00test",
27+
}
28+
}
29+
creds_file = tmp_path / "nebius_creds.json"
30+
creds_file.write_text(json.dumps(creds_json))
31+
32+
config_yaml_path = tmp_path / "config.yml"
33+
config_dict = {
34+
"projects": [
35+
{
36+
"name": "main",
37+
"backends": [
38+
{
39+
"type": "nebius",
40+
"creds": {"type": "service_account", "filename": str(creds_file)},
41+
}
42+
],
43+
}
44+
]
45+
}
46+
config_yaml_path.write_text(yaml.dump(config_dict))
47+
48+
with patch.object(settings, "SERVER_CONFIG_FILE_PATH", config_yaml_path):
49+
m = ServerConfigManager()
50+
assert m.load_config()
51+
assert m.config is not None
52+
assert m.config.projects is not None
53+
assert len(m.config.projects) > 0
54+
assert m.config.projects[0].backends is not None
55+
backend_file_cfg = m.config.projects[0].backends[0]
56+
backend_cfg = file_config_to_config(backend_file_cfg)
57+
58+
assert backend_cfg.type == "nebius"
59+
assert backend_cfg.creds.service_account_id == "serviceaccount-e00test"
60+
assert backend_cfg.creds.public_key_id == "publickey-e00test"
61+
assert (
62+
backend_cfg.creds.private_key_content
63+
== "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n"
64+
)
65+
66+
def test_with_private_key_file(self, tmp_path: Path):
67+
pk_file = tmp_path / "private.key"
68+
pk_file.write_text("TEST_PRIVATE_KEY")
69+
70+
config_yaml_path = tmp_path / "config.yml"
71+
config_dict = {
72+
"projects": [
73+
{
74+
"name": "main",
75+
"backends": [
76+
{
77+
"type": "nebius",
78+
"projects": ["project-e00test"],
79+
"creds": {
80+
"type": "service_account",
81+
"service_account_id": "serviceaccount-e00test",
82+
"public_key_id": "publickey-e00test",
83+
"private_key_file": str(pk_file),
84+
},
85+
}
86+
],
87+
}
88+
]
89+
}
90+
config_yaml_path.write_text(yaml.dump(config_dict))
91+
92+
with patch.object(settings, "SERVER_CONFIG_FILE_PATH", config_yaml_path):
93+
m = ServerConfigManager()
94+
assert m.load_config()
95+
assert m.config is not None
96+
assert m.config.projects is not None
97+
assert len(m.config.projects) > 0
98+
assert m.config.projects[0].backends is not None
99+
backend_file_cfg = m.config.projects[0].backends[0]
100+
backend_cfg = file_config_to_config(backend_file_cfg)
101+
102+
assert backend_cfg.type == "nebius"
103+
assert backend_cfg.creds.service_account_id == "serviceaccount-e00test"
104+
assert backend_cfg.creds.public_key_id == "publickey-e00test"
105+
assert backend_cfg.creds.private_key_content == "TEST_PRIVATE_KEY"

0 commit comments

Comments
 (0)