Files
stackpilot/backend/tests/test_terminal_sync.py
menzeljandClaude Opus 5.5 06234c1067
CI / check (push) Successful in 4m48s
CI / build-and-push (push) Failing after 16m2s
Run compose like the terminal does, and follow changes made there (0.67.2)
StackPilot called `docker compose -p <folder> -f compose.yaml`, which is
not what `docker compose` typed in the stack's folder does. A
compose.override.yaml or a COMPOSE_FILE in .env applied in a terminal
and was dropped by the next Start in StackPilot, which recreated the
containers without it. A compose file's `name:` or a COMPOSE_PROJECT_NAME
in .env made the terminal's containers invisible: the stack showed as
stopped while running, and Start set up a second copy next to it.

Compose now runs in the stack's folder without -f, so it loads the same
files, and -p is the name compose itself picks there: COMPOSE_PROJECT_NAME
from .env, else the last `name:` among the compose files (${VAR} from
.env), else the folder name. A stack StackPilot already runs under its
folder name keeps that name while it has containers or volumes under it
and none under the declared one: switching would have brought it up
again on empty volumes. Backups record the project, and a restore takes
it from the files it has just put back.

Changes made outside StackPilot now reach every view:

- docker_watch_service reads Docker's events once for the backend. The
  dashboard rollup (cached 25s) and the stats cache are stale as soon as
  the event generation moves; the tiles followed a terminal `compose
  stop` after 15-30s, now within a second.
- Image events re-read the local digests, so a `docker compose pull` in
  a terminal clears the update badge instead of leaving it until the
  next registry check (an hour by default).
- The browser catches up when its event socket reconnects; what changed
  while it was down waited for the 60s poll.
- The stack detail carries a revision of the compose file and .env;
  saving after they changed on disk is refused with 409 and the editor
  offers to overwrite or to load the version on disk. A save used to
  replace a terminal edit without a word.
- A stack whose folder is gone is flagged "folder missing" instead of
  passing for an ordinary stopped stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fx7gcuy8d6nPd95g43rGHu
2026-10-15 15:14:40 +02:00

264 lines
9.7 KiB
Python

"""StackPilot and a terminal looking at the same stack (0.67.2).
Before, StackPilot ran ``docker compose -p <folder> -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