Skip to content

Commit 33ddea6

Browse files
authored
Introduce GatewayModel.forbid_new_services (#3863)
Forbidding new services can be useful for migration purposes. For now, there is no user interface for this setting, since we primarily need it for `dstack` Sky.
1 parent 90c0748 commit 33ddea6

5 files changed

Lines changed: 127 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Add GatewayModel.forbid_new_services
2+
3+
Revision ID: 205690dfeec2
4+
Revises: db3679abd063
5+
Create Date: 2026-05-07 17:21:23.415019+00:00
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "205690dfeec2"
14+
down_revision = "db3679abd063"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
with op.batch_alter_table("gateways", schema=None) as batch_op:
22+
batch_op.add_column(
23+
sa.Column(
24+
"forbid_new_services", sa.Boolean(), server_default=sa.false(), nullable=False
25+
)
26+
)
27+
28+
# ### end Alembic commands ###
29+
30+
31+
def downgrade() -> None:
32+
# ### commands auto generated by Alembic - please adjust! ###
33+
with op.batch_alter_table("gateways", schema=None) as batch_op:
34+
batch_op.drop_column("forbid_new_services")
35+
36+
# ### end Alembic commands ###

src/dstack/_internal/server/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,11 @@ class GatewayModel(PipelineModelMixin, BaseModel):
615615
status_message: Mapped[Optional[str]] = mapped_column(Text)
616616
last_processed_at: Mapped[datetime] = mapped_column(NaiveDateTime)
617617
to_be_deleted: Mapped[bool] = mapped_column(Boolean, server_default=false())
618+
forbid_new_services: Mapped[bool] = mapped_column(Boolean, server_default=false())
619+
"""
620+
`forbid_new_services` is useful when migrating off the gateway or doing maintenance.
621+
For now, it can only be set by server admins via an SQL query.
622+
"""
618623

619624
project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
620625
project: Mapped["ProjectModel"] = relationship(foreign_keys=[project_id])

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ async def _register_service_in_gateway(
105105
if gateway.status != GatewayStatus.RUNNING:
106106
raise ServerClientError("Gateway status is not running")
107107

108+
if gateway.forbid_new_services:
109+
raise ServerClientError("Gateway does not accept new services")
110+
108111
gateway_configuration = get_gateway_configuration(gateway)
109112

110113
has_replica_group_router = any(

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,7 @@ async def create_gateway(
639639
gateway_compute_id: Optional[UUID] = None,
640640
status: Optional[GatewayStatus] = GatewayStatus.SUBMITTED,
641641
last_processed_at: datetime = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc),
642+
forbid_new_services: bool = False,
642643
) -> GatewayModel:
643644
gateway = GatewayModel(
644645
project_id=project_id,
@@ -649,6 +650,7 @@ async def create_gateway(
649650
gateway_compute_id=gateway_compute_id,
650651
status=status,
651652
last_processed_at=last_processed_at,
653+
forbid_new_services=forbid_new_services,
652654
)
653655
session.add(gateway)
654656
await session.commit()

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3666,3 +3666,84 @@ async def test_unregister_dangling_service(
36663666
)
36673667
# Verify that register_service was called twice (first failed, then succeeded)
36683668
assert client_mock.register_service.call_count == 2
3669+
3670+
@pytest.mark.asyncio
3671+
async def test_return_error_if_default_gateway_forbids_new_services(
3672+
self,
3673+
test_db,
3674+
session: AsyncSession,
3675+
client: AsyncClient,
3676+
) -> None:
3677+
user = await create_user(session=session, global_role=GlobalRole.USER)
3678+
project = await create_project(session=session, owner=user, name="test-project")
3679+
await add_project_member(
3680+
session=session, project=project, user=user, project_role=ProjectRole.USER
3681+
)
3682+
repo = await create_repo(session=session, project_id=project.id)
3683+
backend = await create_backend(session=session, project_id=project.id)
3684+
gateway_compute = await create_gateway_compute(session=session, backend_id=backend.id)
3685+
gateway = await create_gateway(
3686+
session=session,
3687+
project_id=project.id,
3688+
backend_id=backend.id,
3689+
gateway_compute_id=gateway_compute.id,
3690+
status=GatewayStatus.RUNNING,
3691+
wildcard_domain="example.com",
3692+
forbid_new_services=True,
3693+
)
3694+
project.default_gateway_id = gateway.id
3695+
await session.commit()
3696+
3697+
response = await client.post(
3698+
"/api/project/test-project/runs/submit",
3699+
headers=get_auth_headers(user.token),
3700+
json={"run_spec": get_service_run_spec(repo_id=repo.name, run_name="test-service")},
3701+
)
3702+
3703+
assert response.status_code == 400
3704+
assert response.json() == {
3705+
"detail": [{"msg": "Gateway does not accept new services", "code": "error"}]
3706+
}
3707+
3708+
@pytest.mark.asyncio
3709+
async def test_return_error_if_explicitly_specified_gateway_forbids_new_services(
3710+
self,
3711+
test_db,
3712+
session: AsyncSession,
3713+
client: AsyncClient,
3714+
) -> None:
3715+
user = await create_user(session=session, global_role=GlobalRole.USER)
3716+
project = await create_project(session=session, owner=user, name="test-project")
3717+
await add_project_member(
3718+
session=session, project=project, user=user, project_role=ProjectRole.USER
3719+
)
3720+
repo = await create_repo(session=session, project_id=project.id)
3721+
backend = await create_backend(session=session, project_id=project.id)
3722+
gateway_compute = await create_gateway_compute(session=session, backend_id=backend.id)
3723+
await create_gateway(
3724+
session=session,
3725+
project_id=project.id,
3726+
backend_id=backend.id,
3727+
gateway_compute_id=gateway_compute.id,
3728+
status=GatewayStatus.RUNNING,
3729+
name="restricted-gateway",
3730+
wildcard_domain="example.com",
3731+
forbid_new_services=True,
3732+
)
3733+
3734+
response = await client.post(
3735+
"/api/project/test-project/runs/submit",
3736+
headers=get_auth_headers(user.token),
3737+
json={
3738+
"run_spec": get_service_run_spec(
3739+
repo_id=repo.name,
3740+
run_name="test-service",
3741+
gateway="restricted-gateway",
3742+
)
3743+
},
3744+
)
3745+
3746+
assert response.status_code == 400
3747+
assert response.json() == {
3748+
"detail": [{"msg": "Gateway does not accept new services", "code": "error"}]
3749+
}

0 commit comments

Comments
 (0)