Skip to content

Commit 73e51a5

Browse files
authored
Store source backend config (#3764)
1 parent 6af7fdc commit 73e51a5

File tree

8 files changed

+296
-10
lines changed

8 files changed

+296
-10
lines changed

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ class BackendRecord(CoreModel):
2626
This model includes backend parameters to store in the DB.
2727
"""
2828

29-
# `config` stores text-encoded non-sensitive backend config parameters (e.g. json)
3029
config: str
31-
# `auth` stores text-encoded sensitive backend config parameters (e.g. json).
32-
# Configurator should not encrypt/decrypt it. This is done by the caller.
30+
"""`config` stores text-encoded non-sensitive backend config parameters (e.g. json)
31+
"""
3332
auth: str
33+
"""`auth` stores text-encoded sensitive backend config parameters (e.g. json).
34+
`Configurator` should not encrypt/decrypt it. This is done by the caller.
35+
"""
3436

3537

3638
class StoredBackendRecord(BackendRecord):
@@ -53,8 +55,8 @@ class Configurator(ABC, Generic[BackendConfigWithoutCredsT, BackendConfigWithCre
5355
"""
5456

5557
TYPE: ClassVar[BackendType]
56-
# `BACKEND_CLASS` is used to introspect backend features without initializing it.
5758
BACKEND_CLASS: ClassVar[type[Backend]]
59+
"""`BACKEND_CLASS` is used to introspect backend features without initializing it."""
5860

5961
@abstractmethod
6062
def validate_config(self, config: BackendConfigWithCredsT, default_creds_enabled: bool):
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Add BackendModel.source_config and BackendModel.source_auth
2+
3+
Revision ID: 1b9e2e7e7d35
4+
Revises: ad8c50120507
5+
Create Date: 2026-04-10 12:00:00.000000+00:00
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
import dstack._internal.server.models
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "1b9e2e7e7d35"
16+
down_revision = "ad8c50120507"
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade() -> None:
22+
with op.batch_alter_table("backends", schema=None) as batch_op:
23+
batch_op.add_column(sa.Column("source_config", sa.String(length=20000), nullable=True))
24+
batch_op.add_column(
25+
sa.Column(
26+
"source_auth",
27+
dstack._internal.server.models.EncryptedString(20000),
28+
nullable=True,
29+
)
30+
)
31+
32+
33+
def downgrade() -> None:
34+
with op.batch_alter_table("backends", schema=None) as batch_op:
35+
batch_op.drop_column("source_auth")
36+
batch_op.drop_column("source_config")

src/dstack/_internal/server/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,16 @@ class BackendModel(BaseModel):
317317

318318
config: Mapped[str] = mapped_column(String(20000))
319319
auth: Mapped[DecryptedString] = mapped_column(EncryptedString(20000))
320+
source_config: Mapped[Optional[str]] = mapped_column(String(20000), nullable=True)
321+
"""`source_config` stores the original non-sensitive backend config from user input
322+
before configurators materialize defaults or generated values.
323+
"""
324+
source_auth: Mapped[Optional[DecryptedString]] = mapped_column(
325+
EncryptedString(20000), nullable=True
326+
)
327+
"""`source_auth` stores the original sensitive backend config from user input
328+
before configurators materialize defaults or generated values.
329+
"""
320330

321331
gateways: Mapped[List["GatewayModel"]] = relationship(back_populates="backend")
322332

src/dstack/_internal/server/services/backends/__init__.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import asyncio
22
import heapq
3+
import json
34
import time
45
from collections.abc import Iterable, Iterator
56
from typing import Callable, Coroutine, Dict, List, Optional, Tuple
67
from uuid import UUID
78

89
from cachetools import TTLCache
10+
from pydantic import Field, ValidationError
911
from sqlalchemy import delete, update
1012
from sqlalchemy.ext.asyncio import AsyncSession
13+
from typing_extensions import Annotated
1114

1215
from dstack._internal.core.backends.base.backend import Backend
1316
from dstack._internal.core.backends.base.configurator import (
@@ -33,6 +36,7 @@
3336
ServerClientError,
3437
)
3538
from dstack._internal.core.models.backends.base import BackendType
39+
from dstack._internal.core.models.common import CoreModel
3640
from dstack._internal.core.models.instances import (
3741
InstanceOfferWithAvailability,
3842
)
@@ -46,6 +50,20 @@
4650
logger = get_logger(__name__)
4751

4852

53+
class _BackendConfigWithCreds(CoreModel):
54+
__root__: Annotated[AnyBackendConfigWithCreds, Field(..., discriminator="type")]
55+
56+
57+
def serialize_source_backend_config(
58+
config: AnyBackendConfigWithCreds,
59+
) -> Tuple[str, Optional[str]]:
60+
"""Split user-intent backend config into non-sensitive and sensitive JSON blobs."""
61+
source_config_dict = config.dict()
62+
source_auth = source_config_dict.pop("creds", None)
63+
source_auth_json = None if source_auth is None else json.dumps(source_auth)
64+
return json.dumps(source_config_dict), source_auth_json
65+
66+
4967
async def create_backend(
5068
session: AsyncSession,
5169
project: ProjectModel,
@@ -89,6 +107,8 @@ async def update_backend(
89107
.values(
90108
config=backend.config,
91109
auth=backend.auth,
110+
source_config=backend.source_config,
111+
source_auth=backend.source_auth,
92112
)
93113
)
94114
return config
@@ -99,6 +119,9 @@ async def validate_and_create_backend_model(
99119
configurator: Configurator,
100120
config: AnyBackendConfigWithCreds,
101121
) -> BackendModel:
122+
# Configurators may mutate `config` while building the effective stored backend config,
123+
# so capture the user-intent payload before validation/create_backend runs.
124+
source_config, source_auth = serialize_source_backend_config(config)
102125
await run_async(
103126
configurator.validate_config, config, default_creds_enabled=settings.DEFAULT_CREDS_ENABLED
104127
)
@@ -112,6 +135,8 @@ async def validate_and_create_backend_model(
112135
type=configurator.TYPE,
113136
config=backend_record.config,
114137
auth=DecryptedString(plaintext=backend_record.auth),
138+
source_config=source_config,
139+
source_auth=None if source_auth is None else DecryptedString(plaintext=source_auth),
115140
)
116141

117142

@@ -134,6 +159,16 @@ async def get_backend_config(
134159
return None
135160

136161

162+
async def get_source_backend_config(
163+
project: ProjectModel,
164+
backend_type: BackendType,
165+
) -> Optional[AnyBackendConfigWithCreds]:
166+
backend_model = await get_project_backend_model_by_type(project, backend_type)
167+
if backend_model is None:
168+
return None
169+
return get_source_backend_config_from_backend_model(backend_model)
170+
171+
137172
def get_backend_config_with_creds_from_backend_model(
138173
configurator: Configurator,
139174
backend_model: BackendModel,
@@ -152,6 +187,48 @@ def get_backend_config_without_creds_from_backend_model(
152187
return backend_config
153188

154189

190+
def get_source_backend_config_from_backend_model(
191+
backend_model: BackendModel,
192+
) -> Optional[AnyBackendConfigWithCreds]:
193+
"""Reconstruct user-intent backend config from `source_config`/`source_auth`."""
194+
195+
if backend_model.source_config is None:
196+
return None
197+
try:
198+
source_config_dict = json.loads(backend_model.source_config)
199+
except ValueError:
200+
logger.warning(
201+
"Failed to parse source config for %s backend. Falling back to stored config.",
202+
backend_model.type.value,
203+
)
204+
return None
205+
if backend_model.source_auth is not None:
206+
if not backend_model.source_auth.decrypted:
207+
logger.warning(
208+
"Failed to decrypt source creds for %s backend. Falling back to stored config.",
209+
backend_model.type.value,
210+
)
211+
return None
212+
try:
213+
source_config_dict["creds"] = json.loads(
214+
backend_model.source_auth.get_plaintext_or_error()
215+
)
216+
except ValueError:
217+
logger.warning(
218+
"Failed to parse source creds for %s backend. Falling back to stored config.",
219+
backend_model.type.value,
220+
)
221+
return None
222+
try:
223+
return _BackendConfigWithCreds.parse_obj(source_config_dict).__root__
224+
except ValidationError:
225+
logger.warning(
226+
"Failed to validate source config for %s backend. Falling back to stored config.",
227+
backend_model.type.value,
228+
)
229+
return None
230+
231+
155232
def get_stored_backend_record(backend_model: BackendModel) -> StoredBackendRecord:
156233
return StoredBackendRecord(
157234
config=backend_model.config,

src/dstack/_internal/server/services/config.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ async def _apply_project_config(
142142
backend_config = file_config_to_config(backend_file_config)
143143
backend_type = BackendType(backend_config.type)
144144
backends_to_delete.difference_update([backend_type])
145+
backend_exists = any(backend_type == b.type for b in project.backends)
145146
try:
146147
current_backend_config = await backends_services.get_backend_config(
147148
project=project,
@@ -154,9 +155,15 @@ async def _apply_project_config(
154155
backend_type.value,
155156
)
156157
continue
157-
if backend_config == current_backend_config:
158-
continue
159-
backend_exists = any(backend_type == b.type for b in project.backends)
158+
if current_backend_config is not None:
159+
current_source_backend_config = await backends_services.get_source_backend_config(
160+
project=project,
161+
backend_type=backend_type,
162+
)
163+
# current_source_backend_config may be missing for old backend records
164+
comparable_backend_config = current_source_backend_config or current_backend_config
165+
if backend_config == comparable_backend_config:
166+
continue
160167
try:
161168
# current_backend_config may be None if backend exists
162169
# but it's config is invalid (e.g. cannot be decrypted).

src/dstack/_internal/server/testing/common.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ async def create_backend(
223223
backend_type: BackendType = BackendType.AWS,
224224
config: Optional[Dict] = None,
225225
auth: Optional[Dict] = None,
226+
source_config: Optional[Dict] = None,
227+
source_auth: Optional[Dict] = None,
226228
) -> BackendModel:
227229
if config is None:
228230
config = {
@@ -239,6 +241,10 @@ async def create_backend(
239241
type=backend_type,
240242
config=json.dumps(config),
241243
auth=DecryptedString(plaintext=json.dumps(auth)),
244+
source_config=None if source_config is None else json.dumps(source_config),
245+
source_auth=(
246+
None if source_auth is None else DecryptedString(plaintext=json.dumps(source_auth))
247+
),
242248
)
243249
session.add(backend)
244250
await session.commit()

src/tests/_internal/server/routers/test_backends.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,11 @@ async def test_creates_aws_backend(self, test_db, session: AsyncSession, client:
150150
)
151151
assert response.status_code == 200, response.json()
152152
res = await session.execute(select(BackendModel))
153-
assert len(res.scalars().all()) == 1
153+
backend = res.scalars().one()
154+
assert backend.source_config is not None
155+
assert backend.source_auth is not None
156+
assert json.loads(backend.source_config)["regions"] == ["us-west-1"]
157+
assert json.loads(backend.source_auth.get_plaintext_or_error()) == body["creds"]
154158

155159
@pytest.mark.asyncio
156160
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
@@ -615,6 +619,10 @@ async def test_updates_backend(self, test_db, session: AsyncSession, client: Asy
615619
assert response.status_code == 200, response.json()
616620
await session.refresh(backend)
617621
assert json.loads(backend.config)["regions"] == ["us-east-1"]
622+
assert backend.source_config is not None
623+
assert backend.source_auth is not None
624+
assert json.loads(backend.source_config)["regions"] == ["us-east-1"]
625+
assert json.loads(backend.source_auth.get_plaintext_or_error()) == body["creds"]
618626

619627
@pytest.mark.asyncio
620628
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
@@ -815,7 +823,7 @@ async def test_returns_config_info(self, test_db, session: AsyncSession, client:
815823
"iam_instance_profile": None,
816824
"tags": None,
817825
"os_images": None,
818-
"creds": json.loads(backend.auth.plaintext),
826+
"creds": json.loads(backend.auth.get_plaintext_or_error()),
819827
}
820828

821829

0 commit comments

Comments
 (0)