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
120 lines
3.7 KiB
Python
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()
|
|
|