"""Deleting a stack: what goes with it, and what must not. Before 0.67.1 a deleted stack left its Git source behind — and the poll loop or the next push webhook recreated the folder from the repository and started the stack again. Its auto-update policy was inherited by the next stack of the same name, and every backup schedule naming it failed from then on. Deleting the folder also walked into whatever was mounted inside it (a NAS share at ``./media``) and deleted that too. """ from __future__ import annotations import asyncio import json import os import pytest from sqlmodel import Session, delete, select @pytest.fixture(autouse=True) def clean(db): from database import engine from models.auto_update import AutoUpdate from models.backup_schedule import BackupSchedule from models.git_source import GitSource from models.runtime_state import StackLock def wipe(): with Session(engine) as session: for model in (GitSource, AutoUpdate, BackupSchedule, StackLock): session.exec(delete(model)) session.commit() wipe() yield wipe() def _schedule(session, stack_ids, all_stacks=False): from models.backup_schedule import BackupSchedule, set_stacks schedule = BackupSchedule(destination_id=1, all_stacks=all_stacks) set_stacks(schedule, stack_ids) session.add(schedule) session.commit() session.refresh(schedule) return schedule.id def test_forget_stack_drops_what_refers_to_it(db): from database import engine from models.auto_update import AutoUpdate from models.backup_schedule import BackupSchedule, stacks_of from models.git_source import GitSource from services import stack_cleanup_service with Session(engine) as session: session.add(GitSource(stack_id="gone", url="https://example.invalid/x.git")) session.add(AutoUpdate(stack_id="gone")) session.add(AutoUpdate(stack_id="stays")) both = _schedule(session, ["gone", "stays"]) only = _schedule(session, ["gone"]) everything = _schedule(session, [], all_stacks=True) report = stack_cleanup_service.forget_stack(session, "gone") assert report["git"] is True and report["auto_update"] == 1 assert session.exec(select(GitSource)).all() == [] assert [p.stack_id for p in session.exec(select(AutoUpdate)).all()] == ["stays"] assert stacks_of(session.get(BackupSchedule, both)) == ["stays"] lonely = session.get(BackupSchedule, only) assert stacks_of(lonely) == [] and lonely.enabled is False assert "was deleted" in lonely.last_status assert session.get(BackupSchedule, everything).enabled is True def test_orphans_from_older_versions_are_forgotten_at_startup(db): from database import engine from models.git_source import GitSource from models.stack import Stack from services import stack_cleanup_service with Session(engine) as session: if not session.get(Stack, "alive"): session.add(Stack(id="alive", name="alive")) session.add(GitSource(stack_id="alive", url="https://example.invalid/a.git")) session.add(GitSource(stack_id="long-deleted", url="https://example.invalid/b.git")) session.commit() assert stack_cleanup_service.prune_orphans(session) == ["long-deleted"] assert [g.stack_id for g in session.exec(select(GitSource)).all()] == ["alive"] def test_git_never_syncs_a_stack_that_no_longer_exists(db): from database import engine from models.git_source import GitSource from services import git_service with Session(engine) as session: source = GitSource(stack_id="ghost", url="https://example.invalid/g.git") session.add(source) session.commit() session.refresh(source) with pytest.raises(git_service.GitError, match="no longer exists"): asyncio.run(git_service.sync(session, source)) def test_deleting_a_busy_stack_is_refused(as_admin, db): from database import engine from models.stack import Stack from services import stack_lock_service with Session(engine) as session: if not session.get(Stack, "busy-del"): session.add(Stack(id="busy-del", name="busy")) session.commit() stack_lock_service.acquire(session, "busy-del", "backup", "scheduled") response = as_admin.delete("/api/stacks/busy-del") assert response.status_code == 409 assert "backup" in response.json()["detail"] def test_folder_removal_stops_at_mount_points(tmp_path, monkeypatch): from services import compose_service stack = tmp_path / "media-stack" (stack / "config").mkdir(parents=True) (stack / "config" / "app.db").write_text("x") (stack / "compose.yaml").write_text("services: {}\n") share = stack / "media" share.mkdir() (share / "movie.mkv").write_text("precious") monkeypatch.setattr(compose_service, "_mount_points", lambda: {str(share)}) kept = compose_service.delete_stack_files("media-stack", override=str(tmp_path)) assert kept == [str(share)] assert (share / "movie.mkv").read_text() == "precious" assert not (stack / "config").exists() and not (stack / "compose.yaml").exists() def test_cloning_copies_links_as_links_and_skips_sockets(tmp_path): import socket from services import compose_service src = tmp_path / "orig" src.mkdir() (src / "compose.yaml").write_text("services: {}\n") os.symlink("/definitely/not/there", src / "dangling") sock = socket.socket(socket.AF_UNIX) sock.bind(str(src / "app.sock")) try: compose_service.clone_stack_files("orig", "copy", override=str(tmp_path)) finally: sock.close() dst = tmp_path / "copy" assert (dst / "compose.yaml").exists() assert os.readlink(dst / "dangling") == "/definitely/not/there" assert not os.path.lexists(dst / "app.sock") def test_a_deleted_stack_name_does_not_inherit_its_schedules(as_admin, db, monkeypatch): """End to end through the API, with Docker stubbed out.""" from database import engine from models.backup_schedule import BackupSchedule, stacks_of from services import compose_service async def down(stack_id, override=None): return {"returncode": 0, "stdout": "", "stderr": ""} monkeypatch.setattr(compose_service, "down", down) assert as_admin.post("/api/stacks", json={"name": "del-me"}).status_code == 201 with Session(engine) as session: schedule_id = _schedule(session, ["del-me"]) response = as_admin.delete("/api/stacks/del-me") assert response.status_code == 200, response.text assert response.json()["kept"] == [] with Session(engine) as session: schedule = session.get(BackupSchedule, schedule_id) assert stacks_of(schedule) == [] and not schedule.enabled assert not os.path.isdir(compose_service.stack_dir("del-me")) assert json.loads(as_admin.get("/api/stacks").text) is not None