diff --git a/docs/docs/concepts/backends.md b/docs/docs/concepts/backends.md
index 78b5509ee..e251c3caf 100644
--- a/docs/docs/concepts/backends.md
+++ b/docs/docs/concepts/backends.md
@@ -452,7 +452,7 @@ There are two ways to configure GCP: using a service account or using the defaul
- name: main
backends:
- type: gcp
- project_id: gcp-project-id
+ project_id: my-gcp-project
creds:
type: service_account
filename: ~/.dstack/server/gcp-024ed630eab5.json
@@ -460,6 +460,35 @@ There are two ways to configure GCP: using a service account or using the defaul
+ ??? info "User interface"
+ If you are configuring the `gcp` backend on the [project settigns page](projects.md#backends),
+ specify the contents of the JSON file in `data`:
+
+
+
+ ```yaml
+ type: gcp
+ project_id: my-gcp-project
+ creds:
+ type: service_account
+ data: |
+ {
+ "type": "service_account",
+ "project_id": "my-gcp-project",
+ "private_key_id": "abcd1234efgh5678ijkl9012mnop3456qrst7890",
+ "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEv...rest_of_key...IDAQAB\n-----END PRIVATE KEY-----\n",
+ "client_email": "my-service-account@my-gcp-project.iam.gserviceaccount.com",
+ "client_id": "123456789012345678901",
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+ "token_uri": "https://oauth2.googleapis.com/token",
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/my-service-account%40my-gcp-project.iam.gserviceaccount.com",
+ "universe_domain": "googleapis.com"
+ }
+ ```
+
+
+
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):
```shell
@@ -638,8 +667,33 @@ projects:
-??? info "Configuring in the UI"
- If you are configuring the backend in the `dstack` UI, specify the contents of the private key file in `private_key_content`.
+??? info "Credentials file"
+ 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:
+
+
+
+ ```shell
+ $ nebius iam auth-public-key generate \
+ --service-account-id \
+ --output ~/.nebius/sa-credentials.json
+ ```
+
+
+
+
+ ```yaml
+ projects:
+ - name: main
+ backends:
+ - type: nebius
+ creds:
+ type: service_account
+ filename: ~/.nebius/sa-credentials.json
+ ```
+
+??? info "User interface"
+ If you are configuring the `nebius` backend on the [project settigns page](projects.md#backends),
+ specify the contents of the private key file in `private_key_content`:
diff --git a/src/dstack/_internal/core/backends/nebius/models.py b/src/dstack/_internal/core/backends/nebius/models.py
index d7eb3a9ed..27a2b7c1a 100644
--- a/src/dstack/_internal/core/backends/nebius/models.py
+++ b/src/dstack/_internal/core/backends/nebius/models.py
@@ -1,3 +1,5 @@
+import json
+from pathlib import Path
from typing import Annotated, Literal, Optional, Union
from pydantic import Field, root_validator
@@ -27,16 +29,38 @@ class NebiusServiceAccountCreds(CoreModel):
)
),
]
+ filename: Annotated[
+ Optional[str], Field(description="The path to the service account credentials file")
+ ] = None
class NebiusServiceAccountFileCreds(CoreModel):
type: Annotated[Literal["service_account"], Field(description="The type of credentials")] = (
"service_account"
)
- service_account_id: Annotated[str, Field(description="Service account ID")]
- public_key_id: Annotated[str, Field(description="ID of the service account public key")]
+ service_account_id: Annotated[
+ Optional[str],
+ Field(
+ description=(
+ "Service account ID. Set automatically if `filename` is specified. When configuring via the UI, it must be specified explicitly"
+ )
+ ),
+ ] = None
+ public_key_id: Annotated[
+ Optional[str],
+ Field(
+ description=(
+ "ID of the service account public key. Set automatically if `filename` is specified. When configuring via the UI, it must be specified explicitly"
+ )
+ ),
+ ] = None
private_key_file: Annotated[
- Optional[str], Field(description=("Path to the service account private key"))
+ Optional[str],
+ Field(
+ description=(
+ "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"
+ )
+ ),
] = None
private_key_content: Annotated[
Optional[str],
@@ -44,13 +68,35 @@ class NebiusServiceAccountFileCreds(CoreModel):
description=(
"Content of the service account private key. When configuring via"
" `server/config.yml`, it's automatically filled from `private_key_file`."
- " When configuring via UI, it has to be specified explicitly."
+ " When configuring via UI, it has to be specified explicitly"
)
),
] = None
+ filename: Annotated[
+ Optional[str], Field(description="The path to the service account credentials file")
+ ] = None
@root_validator
def fill_data(cls, values):
+ if filename := values.get("filename"):
+ try:
+ with open(Path(filename).expanduser()) as f:
+ data = json.load(f)
+ from nebius.base.service_account.credentials_file import (
+ ServiceAccountCredentials,
+ )
+
+ credentials = ServiceAccountCredentials.from_json(data)
+ subject = credentials.subject_credentials
+ values["service_account_id"] = subject.sub
+ values["public_key_id"] = subject.kid
+ values["private_key_content"] = subject.private_key
+ except OSError:
+ raise ValueError(f"No such file {filename}")
+ except Exception as e:
+ raise ValueError(f"Failed to parse credentials file {filename}: {e}")
+ return values
+
return fill_data(
values, filename_field="private_key_file", data_field="private_key_content"
)
diff --git a/src/tests/_internal/server/services/test_backend_configs.py b/src/tests/_internal/server/services/test_backend_configs.py
new file mode 100644
index 000000000..9ac2e3bbd
--- /dev/null
+++ b/src/tests/_internal/server/services/test_backend_configs.py
@@ -0,0 +1,105 @@
+import json
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+import yaml
+
+from dstack._internal.server import settings
+from dstack._internal.server.services.config import (
+ ServerConfigManager,
+ file_config_to_config,
+)
+
+
+@pytest.mark.skipif(sys.version_info < (3, 10), reason="Nebius requires Python 3.10")
+class TestNebiusBackendConfig:
+ def test_with_filename(self, tmp_path: Path):
+ creds_json = {
+ "subject-credentials": {
+ "type": "JWT",
+ "alg": "RS256",
+ "private-key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n",
+ "kid": "publickey-e00test",
+ "iss": "serviceaccount-e00test",
+ "sub": "serviceaccount-e00test",
+ }
+ }
+ creds_file = tmp_path / "nebius_creds.json"
+ creds_file.write_text(json.dumps(creds_json))
+
+ config_yaml_path = tmp_path / "config.yml"
+ config_dict = {
+ "projects": [
+ {
+ "name": "main",
+ "backends": [
+ {
+ "type": "nebius",
+ "creds": {"type": "service_account", "filename": str(creds_file)},
+ }
+ ],
+ }
+ ]
+ }
+ config_yaml_path.write_text(yaml.dump(config_dict))
+
+ with patch.object(settings, "SERVER_CONFIG_FILE_PATH", config_yaml_path):
+ m = ServerConfigManager()
+ assert m.load_config()
+ assert m.config is not None
+ assert m.config.projects is not None
+ assert len(m.config.projects) > 0
+ assert m.config.projects[0].backends is not None
+ backend_file_cfg = m.config.projects[0].backends[0]
+ backend_cfg = file_config_to_config(backend_file_cfg)
+
+ assert backend_cfg.type == "nebius"
+ assert backend_cfg.creds.service_account_id == "serviceaccount-e00test"
+ assert backend_cfg.creds.public_key_id == "publickey-e00test"
+ assert (
+ backend_cfg.creds.private_key_content
+ == "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n"
+ )
+
+ def test_with_private_key_file(self, tmp_path: Path):
+ pk_file = tmp_path / "private.key"
+ pk_file.write_text("TEST_PRIVATE_KEY")
+
+ config_yaml_path = tmp_path / "config.yml"
+ config_dict = {
+ "projects": [
+ {
+ "name": "main",
+ "backends": [
+ {
+ "type": "nebius",
+ "projects": ["project-e00test"],
+ "creds": {
+ "type": "service_account",
+ "service_account_id": "serviceaccount-e00test",
+ "public_key_id": "publickey-e00test",
+ "private_key_file": str(pk_file),
+ },
+ }
+ ],
+ }
+ ]
+ }
+ config_yaml_path.write_text(yaml.dump(config_dict))
+
+ with patch.object(settings, "SERVER_CONFIG_FILE_PATH", config_yaml_path):
+ m = ServerConfigManager()
+ assert m.load_config()
+ assert m.config is not None
+ assert m.config.projects is not None
+ assert len(m.config.projects) > 0
+ assert m.config.projects[0].backends is not None
+ backend_file_cfg = m.config.projects[0].backends[0]
+ backend_cfg = file_config_to_config(backend_file_cfg)
+
+ assert backend_cfg.type == "nebius"
+ assert backend_cfg.creds.service_account_id == "serviceaccount-e00test"
+ assert backend_cfg.creds.public_key_id == "publickey-e00test"
+ assert backend_cfg.creds.private_key_content == "TEST_PRIVATE_KEY"