Files
stackpilot/backend/services/docker_watch_service.py
T
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

120 lines
3.7 KiB
Python

"""One backend-wide reader of Docker's event stream, for server-side caches.
The browser follows Docker on its own (``/ws/events``) and refetches whatever
an event touched. Two of the answers it refetched were cached here, though,
and did not know anything had happened:
* the dashboard's fleet rollup, held for 25 seconds — a stack stopped in a
terminal showed as running in the dashboard tiles for up to half a minute,
and the event that should have refreshed them had come and gone;
* the image update status, whose local digests were only re-read after a
pull *through StackPilot* — after ``docker compose pull && up -d`` in a
terminal the "update available" badge stayed until the next registry check,
an hour by default.
So: :func:`generation` changes with every container or image event, and a
cache that remembers the generation it was computed at knows when it is stale.
Image events also re-read the local digests (no registry calls), debounced so
a ``pull`` of ten layers is one pass.
"""
from __future__ import annotations
import logging
import threading
logger = logging.getLogger("stackpilot.docker_watch")
_EVENT_TYPES = ["container", "image"]
#: Container actions that change nothing a cache holds (see routers/ws.py).
_IGNORED = {
"exec_create", "exec_start", "exec_die", "exec_detach",
"attach", "top", "resize", "archive-path", "extract-to-dir",
}
#: Wait this long after the last image event before re-reading digests.
DIGEST_DEBOUNCE_S = 2.0
RECONNECT_MAX_S = 30.0
_generation = 0
_lock = threading.Lock()
_stop = threading.Event()
_thread: threading.Thread | None = None
_digest_timer: threading.Timer | None = None
def generation() -> int:
"""Changes whenever Docker reports a container or image change."""
return _generation
def bump() -> None:
global _generation
with _lock:
_generation += 1
def _refresh_digests() -> None:
from services import update_service
try:
changed = update_service.refresh_local_digests()
if changed:
logger.info("Re-read local digests after image events: %d changed", changed)
except Exception as exc: # noqa: BLE001 - a cache refresh must never kill the watcher
logger.debug("Local digest refresh failed: %s", exc)
def _schedule_digest_refresh() -> None:
global _digest_timer
with _lock:
if _digest_timer is not None:
_digest_timer.cancel()
_digest_timer = threading.Timer(DIGEST_DEBOUNCE_S, _refresh_digests)
_digest_timer.daemon = True
_digest_timer.start()
def handle(event: dict) -> None:
"""Apply one Docker event to the caches."""
kind = event.get("Type")
action = (event.get("Action") or "").split(":")[0]
if kind == "container" and action in _IGNORED:
return
bump()
if kind == "image":
_schedule_digest_refresh()
def _run() -> None:
from docker_client import get_client
delay = 1.0
while not _stop.is_set():
try:
stream = get_client().events(decode=True, filters={"type": _EVENT_TYPES})
delay = 1.0
# Anything may have happened while we were not listening.
bump()
for event in stream:
if _stop.is_set():
break
handle(event)
except Exception as exc: # noqa: BLE001 - daemon restarts, socket hiccups
logger.debug("Docker event stream ended: %s", exc)
if _stop.wait(delay):
break
delay = min(delay * 2, RECONNECT_MAX_S)
def start() -> None:
global _thread
if _thread is not None and _thread.is_alive():
return
_stop.clear()
_thread = threading.Thread(target=_run, name="docker-watch", daemon=True)
_thread.start()
def stop() -> None:
_stop.set()