Skip to content

Commit b7ed1b3

Browse files
[Feature]: Nebius switch to using nebius iam auth-public-key generate (#3147)
1 parent 1adeb88 commit b7ed1b3

3 files changed

Lines changed: 212 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: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
from pathlib import Path
13
from typing import Annotated, Literal, Optional, Union
24

35
from pydantic import Field, root_validator
@@ -27,30 +29,74 @@ class NebiusServiceAccountCreds(CoreModel):
2729
)
2830
),
2931
]
32+
filename: Annotated[
33+
Optional[str], Field(description="The path to the service account credentials file")
34+
] = None
3035

3136

3237
class NebiusServiceAccountFileCreds(CoreModel):
3338
type: Annotated[Literal["service_account"], Field(description="The type of credentials")] = (
3439
"service_account"
3540
)
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")]
41+
service_account_id: Annotated[
42+
Optional[str],
43+
Field(
44+
description=(
45+
"Service account ID. Set automatically if `filename` is specified. When configuring via the UI, it must be specified explicitly"
46+
)
47+
),
48+
] = None
49+
public_key_id: Annotated[
50+
Optional[str],
51+
Field(
52+
description=(
53+
"ID of the service account public key. Set automatically if `filename` is specified. When configuring via the UI, it must be specified explicitly"
54+
)
55+
),
56+
] = None
3857
private_key_file: Annotated[
39-
Optional[str], Field(description=("Path to the service account private key"))
58+
Optional[str],
59+
Field(
60+
description=(
61+
"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"
62+
)
63+
),
4064
] = None
4165
private_key_content: Annotated[
4266
Optional[str],
4367
Field(
4468
description=(
4569
"Content of the service account private key. When configuring via"
4670
" `server/config.yml`, it's automatically filled from `private_key_file`."
47-
" When configuring via UI, it has to be specified explicitly."
71+
" When configuring via UI, it has to be specified explicitly"
4872
)
4973
),
5074
] = None
75+
filename: Annotated[
76+
Optional[str], Field(description="The path to the service account credentials file")
77+
] = None
5178

5279
@root_validator
5380
def fill_data(cls, values):
81+
if filename := values.get("filename"):
82+
try:
83+
with open(Path(filename).expanduser()) as f:
84+
data = json.load(f)
85+
from nebius.base.service_account.credentials_file import (
86+
ServiceAccountCredentials,
87+
)
88+
89+
credentials = ServiceAccountCredentials.from_json(data)
90+
subject = credentials.subject_credentials
91+
values["service_account_id"] = subject.sub
92+
values["public_key_id"] = subject.kid
93+
values["private_key_content"] = subject.private_key
94+
except OSError:
95+
raise ValueError(f"No such file {filename}")
96+
except Exception as e:
97+
raise ValueError(f"Failed to parse credentials file {filename}: {e}")
98+
return values
99+
54100
return fill_data(
55101
values, filename_field="private_key_file", data_field="private_key_content"
56102
)
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)