Skip to content

Commit e46a12e

Browse files
authored
Forbid deleting projects with active resources (#3079)
* Forbid deleting projects with active resources * Handle active fleet in deleted project
1 parent b6aa7e8 commit e46a12e

File tree

4 files changed

+180
-3
lines changed

4 files changed

+180
-3
lines changed

src/dstack/_internal/server/background/tasks/process_fleets.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,14 @@ def _maintain_fleet_nodes_min(
177177

178178

179179
def _autodelete_fleet(fleet_model: FleetModel) -> bool:
180+
if fleet_model.project.deleted:
181+
# It used to be possible to delete project with active resources:
182+
# https://github.com/dstackai/dstack/issues/3077
183+
fleet_model.status = FleetStatus.TERMINATED
184+
fleet_model.deleted = True
185+
logger.info("Fleet %s deleted due to deleted project", fleet_model.name)
186+
return True
187+
180188
if is_fleet_in_use(fleet_model) or not is_fleet_empty(fleet_model):
181189
return False
182190

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ async def delete_backends_safe(
2020
error: bool = True,
2121
):
2222
try:
23+
# FIXME: The checks are not under lock,
24+
# so there can be dangling active resources due to race conditions.
2325
await _check_active_instances(
2426
session=session,
2527
project=project,

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

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,16 @@
1414
from dstack._internal.core.backends.models import BackendInfo
1515
from dstack._internal.core.errors import ForbiddenError, ResourceExistsError, ServerClientError
1616
from dstack._internal.core.models.projects import Member, MemberPermissions, Project
17+
from dstack._internal.core.models.runs import RunStatus
1718
from dstack._internal.core.models.users import GlobalRole, ProjectRole
18-
from dstack._internal.server.models import MemberModel, ProjectModel, UserModel
19+
from dstack._internal.server.models import (
20+
FleetModel,
21+
MemberModel,
22+
ProjectModel,
23+
RunModel,
24+
UserModel,
25+
VolumeModel,
26+
)
1927
from dstack._internal.server.schemas.projects import MemberSetting
2028
from dstack._internal.server.services import users
2129
from dstack._internal.server.services.backends import (
@@ -178,6 +186,19 @@ async def delete_projects(
178186
raise ForbiddenError()
179187
if all(name in projects_names for name in user_project_names):
180188
raise ServerClientError("Cannot delete the only project")
189+
190+
res = await session.execute(
191+
select(ProjectModel.id).where(ProjectModel.name.in_(projects_names))
192+
)
193+
project_ids = res.scalars().all()
194+
if len(project_ids) != len(projects_names):
195+
raise ServerClientError("Failed to delete non-existent projects")
196+
197+
for project_id in project_ids:
198+
# FIXME: The checks are not under lock,
199+
# so there can be dangling active resources due to race conditions.
200+
await _check_project_has_active_resources(session=session, project_id=project_id)
201+
181202
timestamp = str(int(get_current_datetime().timestamp()))
182203
new_project_name = "_deleted_" + timestamp + ProjectModel.name
183204
await session.execute(
@@ -614,6 +635,36 @@ def _is_project_admin(
614635
return False
615636

616637

638+
async def _check_project_has_active_resources(session: AsyncSession, project_id: uuid.UUID):
639+
res = await session.execute(
640+
select(RunModel.run_name).where(
641+
RunModel.project_id == project_id,
642+
RunModel.status.not_in(RunStatus.finished_statuses()),
643+
)
644+
)
645+
run_names = list(res.scalars().all())
646+
if len(run_names) > 0:
647+
raise ServerClientError(f"Failed to delete project with active runs: {run_names}")
648+
res = await session.execute(
649+
select(FleetModel.name).where(
650+
FleetModel.project_id == project_id,
651+
FleetModel.deleted.is_(False),
652+
)
653+
)
654+
fleet_names = list(res.scalars().all())
655+
if len(fleet_names) > 0:
656+
raise ServerClientError(f"Failed to delete project with active fleets: {fleet_names}")
657+
res = await session.execute(
658+
select(VolumeModel.name).where(
659+
VolumeModel.project_id == project_id,
660+
VolumeModel.deleted.is_(False),
661+
)
662+
)
663+
volume_names = list(res.scalars().all())
664+
if len(volume_names) > 0:
665+
raise ServerClientError(f"Failed to delete project with active volumes: {volume_names}")
666+
667+
617668
async def remove_project_members(
618669
session: AsyncSession,
619670
user: UserModel,

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

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,20 @@
88
from sqlalchemy import select
99
from sqlalchemy.ext.asyncio import AsyncSession
1010

11+
from dstack._internal.core.models.fleets import FleetStatus
12+
from dstack._internal.core.models.runs import RunStatus
1113
from dstack._internal.core.models.users import GlobalRole, ProjectRole
1214
from dstack._internal.server.models import MemberModel, ProjectModel
1315
from dstack._internal.server.services.permissions import DefaultPermissions
1416
from dstack._internal.server.services.projects import add_project_member
1517
from dstack._internal.server.testing.common import (
1618
create_backend,
19+
create_fleet,
1720
create_project,
21+
create_repo,
22+
create_run,
1823
create_user,
24+
create_volume,
1925
default_permissions_context,
2026
get_auth_headers,
2127
)
@@ -484,6 +490,19 @@ async def test_deletes_projects(self, test_db, session: AsyncSession, client: As
484490
assert project1.deleted
485491
assert not project2.deleted
486492

493+
@pytest.mark.asyncio
494+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
495+
async def test_returns_400_if_project_does_not_exist(
496+
self, test_db, session: AsyncSession, client: AsyncClient
497+
):
498+
user = await create_user(session=session, global_role=GlobalRole.ADMIN)
499+
response = await client.post(
500+
"/api/projects/delete",
501+
headers=get_auth_headers(user.token),
502+
json={"projects_names": ["random_project"]},
503+
)
504+
assert response.status_code == 400
505+
487506
@pytest.mark.asyncio
488507
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
489508
async def test_returns_403_if_not_project_admin(
@@ -505,7 +524,7 @@ async def test_returns_403_if_not_project_admin(
505524
json={"projects_names": [project1.name, project2.name]},
506525
)
507526
assert response.status_code == 403
508-
res = await session.execute(select(ProjectModel))
527+
res = await session.execute(select(ProjectModel).where(ProjectModel.deleted.is_(False)))
509528
assert len(res.all()) == 2
510529

511530
@pytest.mark.asyncio
@@ -521,8 +540,105 @@ async def test_returns_403_if_not_project_member(
521540
json={"projects_names": [project.name]},
522541
)
523542
assert response.status_code == 403
524-
res = await session.execute(select(ProjectModel))
543+
res = await session.execute(select(ProjectModel).where(ProjectModel.deleted.is_(False)))
544+
assert len(res.all()) == 1
545+
546+
@pytest.mark.asyncio
547+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
548+
async def test_errors_if_project_has_active_runs(
549+
self, test_db, session: AsyncSession, client: AsyncClient
550+
):
551+
user = await create_user(session=session, global_role=GlobalRole.ADMIN)
552+
project = await create_project(session=session, name="project")
553+
repo = await create_repo(session=session, project_id=project.id)
554+
run = await create_run(
555+
session=session,
556+
project=project,
557+
repo=repo,
558+
user=user,
559+
status=RunStatus.SUBMITTED,
560+
)
561+
response = await client.post(
562+
"/api/projects/delete",
563+
headers=get_auth_headers(user.token),
564+
json={"projects_names": [project.name]},
565+
)
566+
assert response.status_code == 400
567+
res = await session.execute(select(ProjectModel).where(ProjectModel.deleted.is_(False)))
568+
assert len(res.all()) == 1
569+
run.status = RunStatus.TERMINATED
570+
await session.commit()
571+
response = await client.post(
572+
"/api/projects/delete",
573+
headers=get_auth_headers(user.token),
574+
json={"projects_names": [project.name]},
575+
)
576+
assert response.status_code == 200
577+
res = await session.execute(select(ProjectModel).where(ProjectModel.deleted.is_(False)))
578+
assert len(res.all()) == 0
579+
580+
@pytest.mark.asyncio
581+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
582+
async def test_errors_if_project_has_active_fleets(
583+
self, test_db, session: AsyncSession, client: AsyncClient
584+
):
585+
user = await create_user(session=session, global_role=GlobalRole.ADMIN)
586+
project = await create_project(session=session, name="project")
587+
fleet = await create_fleet(
588+
session=session,
589+
project=project,
590+
deleted=False,
591+
)
592+
response = await client.post(
593+
"/api/projects/delete",
594+
headers=get_auth_headers(user.token),
595+
json={"projects_names": [project.name]},
596+
)
597+
assert response.status_code == 400
598+
res = await session.execute(select(ProjectModel).where(ProjectModel.deleted.is_(False)))
525599
assert len(res.all()) == 1
600+
fleet.status = FleetStatus.TERMINATED
601+
fleet.deleted = True
602+
await session.commit()
603+
response = await client.post(
604+
"/api/projects/delete",
605+
headers=get_auth_headers(user.token),
606+
json={"projects_names": [project.name]},
607+
)
608+
assert response.status_code == 200
609+
res = await session.execute(select(ProjectModel).where(ProjectModel.deleted.is_(False)))
610+
assert len(res.all()) == 0
611+
612+
@pytest.mark.asyncio
613+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
614+
async def test_errors_if_project_has_active_volumes(
615+
self, test_db, session: AsyncSession, client: AsyncClient
616+
):
617+
user = await create_user(session=session, global_role=GlobalRole.ADMIN)
618+
project = await create_project(session=session, name="project")
619+
volume = await create_volume(
620+
session=session,
621+
project=project,
622+
user=user,
623+
)
624+
response = await client.post(
625+
"/api/projects/delete",
626+
headers=get_auth_headers(user.token),
627+
json={"projects_names": [project.name]},
628+
)
629+
assert response.status_code == 400
630+
res = await session.execute(select(ProjectModel).where(ProjectModel.deleted.is_(False)))
631+
assert len(res.all()) == 1
632+
volume.deleted = True
633+
await session.commit()
634+
response = await client.post(
635+
"/api/projects/delete",
636+
headers=get_auth_headers(user.token),
637+
json={"projects_names": [project.name]},
638+
)
639+
assert response.status_code == 200
640+
res = await session.execute(select(ProjectModel).where(ProjectModel.deleted.is_(False)))
641+
assert len(res.all()) == 0
526642

527643

528644
class TestGetProject:

0 commit comments

Comments
 (0)