"""StackPilot and a terminal looking at the same stack (0.67.2). Before, StackPilot ran ``docker compose -p -f compose.yaml``, which is not what ``docker compose`` typed in the folder does: an override file or a ``COMPOSE_FILE`` in ``.env`` was dropped by every Start, and a ``name:`` or ``COMPOSE_PROJECT_NAME`` made the terminal's containers invisible to it. Its caches did not hear about changes made outside it either, and the editor overwrote a file changed on disk without a word. """ from __future__ import annotations import os import pytest @pytest.fixture def folder(tmp_path): from services import compose_service compose_service._DECLARED.clear() d = tmp_path / "photos" d.mkdir() (d / "compose.yaml").write_text("services:\n app:\n image: busybox\n") return d def test_the_folder_names_the_project_when_nothing_else_does(folder): from services import compose_service assert compose_service.declared_project(str(folder)) is None def test_name_in_the_compose_file(folder): from services import compose_service (folder / "compose.yaml").write_text("name: Immich\nservices: {}\n") assert compose_service.declared_project(str(folder)) == "immich" def test_an_override_files_name_wins_and_variables_come_from_dotenv(folder): from services import compose_service (folder / "compose.yaml").write_text("name: base\nservices: {}\n") (folder / "docker-compose.override.yml").write_text("name: ${PROJECT:-fallback}\n") (folder / ".env").write_text('PROJECT="from-env"\n') assert compose_service.declared_project(str(folder)) == "from-env" def test_compose_project_name_in_dotenv_wins_over_name(folder): from services import compose_service (folder / "compose.yaml").write_text("name: from-file\nservices: {}\n") (folder / ".env").write_text("COMPOSE_PROJECT_NAME=from-dotenv\n") assert compose_service.declared_project(str(folder)) == "from-dotenv" def test_a_changed_file_is_read_again(folder): from services import compose_service assert compose_service.declared_project(str(folder)) is None (folder / "compose.yaml").write_text("name: renamed\nservices: {}\n") os.utime(folder / "compose.yaml", ns=(1, 2_000_000_000_000_000_000)) assert compose_service.declared_project(str(folder)) == "renamed" def test_the_files_compose_loads(folder): from services import compose_service (folder / "compose.override.yaml").write_text("services: {}\n") assert [os.path.basename(f) for f in compose_service.compose_files(str(folder))] == [ "compose.yaml", "compose.override.yaml", ] (folder / "gpu.yaml").write_text("services: {}\n") (folder / ".env").write_text("COMPOSE_FILE=compose.yaml:gpu.yaml\n") assert [os.path.basename(f) for f in compose_service.compose_files(str(folder))] == [ "compose.yaml", "gpu.yaml", ] @pytest.fixture def named_stack(tmp_path, monkeypatch): from config import settings from services import compose_service monkeypatch.setattr(settings, "STACKS_DIR", str(tmp_path)) compose_service._DECLARED.clear() d = tmp_path / "photos" d.mkdir() (d / "compose.yaml").write_text("name: immich\nservices: {}\n") return d def test_a_declared_name_is_the_project(named_stack, monkeypatch): from services import compose_service monkeypatch.setattr(compose_service, "_has_resources", lambda project: False) assert compose_service.project_name("photos") == "immich" def test_a_stack_started_under_its_folder_name_keeps_it(named_stack, monkeypatch): """Switching would orphan its containers and start it again on empty volumes.""" from services import compose_service monkeypatch.setattr(compose_service, "_has_resources", lambda project: project == "photos") assert compose_service.project_name("photos") == "photos" def test_compose_runs_like_it_does_in_the_folder(named_stack, monkeypatch): from services import compose_service monkeypatch.setattr(compose_service, "_has_resources", lambda project: False) cmd = compose_service._compose_base_cmd("photos") assert "-f" not in cmd assert cmd[cmd.index("-p") + 1] == "immich" assert compose_service._compose_cwd("photos") == str(named_stack) assert compose_service._compose_cwd("gone") is None # --------------------------------------------------------------------------- # # Saving over a file changed on disk. # --------------------------------------------------------------------------- # def _forget(stack_id: str) -> None: from sqlmodel import Session from database import engine from models.stack import Stack from services import compose_service compose_service.delete_stack_files(stack_id) with Session(engine) as session: row = session.get(Stack, stack_id) if row: session.delete(row) session.commit() def test_a_save_over_a_change_made_on_disk_is_refused(as_admin, db): from services import compose_service assert as_admin.post("/api/stacks", json={"name": "raced", "yaml": "services: {}\n"}).status_code == 201 try: loaded = as_admin.get("/api/stacks/raced").json()["revision"] with open(compose_service.compose_path("raced"), "a", encoding="utf-8") as fh: fh.write("# edited in a terminal\n") refused = as_admin.put( "/api/stacks/raced", json={"yaml": "services: {}\n# mine\n", "base_revision": loaded} ) assert refused.status_code == 409 assert refused.json()["detail"]["error"] == "changed_on_disk" assert "edited in a terminal" in compose_service.read_compose("raced") # Taking the current revision — or none, to overwrite — goes through. current = refused.json()["detail"]["revision"] saved = as_admin.put("/api/stacks/raced", json={"yaml": "services: {}\n", "base_revision": current}) assert saved.status_code == 200 assert saved.json()["revision"] == compose_service.stack_revision("raced") finally: _forget("raced") def test_the_revision_is_withheld_from_the_read_only_role(as_admin, as_user, db): assert as_admin.post("/api/stacks", json={"name": "rev-viewer"}).status_code == 201 try: assert as_user.get("/api/stacks/rev-viewer").json()["revision"] is None finally: _forget("rev-viewer") def test_a_stack_whose_folder_is_gone_says_so(as_admin, db): import shutil from services import compose_service assert as_admin.post("/api/stacks", json={"name": "vanished"}).status_code == 201 try: shutil.rmtree(compose_service.stack_dir("vanished")) rows = {s["id"]: s for s in as_admin.get("/api/stacks").json()} assert rows["vanished"]["missing"] is True assert as_admin.get("/api/stacks/vanished").json()["missing"] is True finally: _forget("vanished") # --------------------------------------------------------------------------- # # Server-side caches following Docker. # --------------------------------------------------------------------------- # def test_docker_events_move_the_generation_but_execs_do_not(monkeypatch): from services import docker_watch_service as watch monkeypatch.setattr(watch, "_schedule_digest_refresh", lambda: None) before = watch.generation() watch.handle({"Type": "container", "Action": "exec_start: sh"}) assert watch.generation() == before watch.handle({"Type": "container", "Action": "die"}) assert watch.generation() == before + 1 def test_an_image_event_rereads_the_local_digests(monkeypatch): from services import docker_watch_service as watch calls = [] monkeypatch.setattr(watch, "_schedule_digest_refresh", lambda: calls.append(1)) watch.handle({"Type": "image", "Action": "pull"}) assert calls == [1] def test_a_pull_made_elsewhere_clears_the_update_badge(monkeypatch): """After `docker compose pull` in a terminal the local digest is the registry's — the badge used to stay until the next registry check.""" import time from services import update_service status = update_service.UpdateStatus( image="nginx:alpine", update_available=True, current_digest="sha256:old", remote_digest="sha256:new", checked_at=time.time(), ) monkeypatch.setattr(update_service, "_CACHE", {"nginx:alpine": status}) monkeypatch.setattr(update_service, "_store", lambda status, notified: None) monkeypatch.setattr(update_service, "_local_digest", lambda image, attrs=None: "sha256:new") assert update_service.refresh_local_digests() == 1 assert status.update_available is False and status.current_digest == "sha256:new" assert update_service.refresh_local_digests() == 0 def test_the_fleet_rollup_is_recomputed_after_a_docker_change(monkeypatch, db): import asyncio from sqlmodel import Session from database import engine from services import dashboard_service, docker_watch_service computed = [] def local_host(): computed.append(1) host = { "stacks": {"running": 0, "partial": 0, "total": 0, "stopped": 0, "error": 0}, "containers_running": 0, "containers_total": 0, "unhealthy": 0, "updates_available": 0, } return host, [] monkeypatch.setattr(dashboard_service, "_local_host", local_host) monkeypatch.setattr(dashboard_service, "_backup_attention", lambda session: []) monkeypatch.setattr(dashboard_service, "_fleet_cache", {"data": None, "ts": 0.0, "gen": None}) with Session(engine) as session: asyncio.run(dashboard_service.compute_fleet(session)) asyncio.run(dashboard_service.compute_fleet(session)) assert len(computed) == 1 # cached docker_watch_service.bump() asyncio.run(dashboard_service.compute_fleet(session)) assert len(computed) == 2