Run compose like the terminal does, and follow changes made there (0.67.2)
CI / check (push) Successful in 4m48s
CI / build-and-push (push) Failing after 16m2s

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
This commit is contained in:
menzeljandClaude Opus 5.5 committed 2026-10-15 15:14:40 +02:00
1 parent d6cebb3371
commit 06234c1067
21 files changed
+811 -45

No files matched your search

+187 -12
View File
@@ -37,17 +37,167 @@ class StackFileError(Exception):
_PROJECT_INVALID_RE = re.compile(r"[^a-z0-9_-]+")
def project_name(stack_id: str) -> str:
"""The compose project name for a stack.
def _normalize_project(name: str) -> str:
return _PROJECT_INVALID_RE.sub("", name.lower()).lstrip("_-")
The same normalisation compose applies to a directory name when it picks
the project name itself (lowercase, only ``a-z0-9_-``, no leading ``-``/``_``).
For every stack StackPilot creates that is the id unchanged. A folder found
on disk as ``Nextcloud`` or ``my.app`` used to be passed as ``-p Nextcloud``,
which compose refuses — so every action on such a stack failed, while
``docker compose up`` in that folder worked.
#: The override files compose loads next to the main file, in its order.
COMPOSE_OVERRIDE_FILENAMES = (
"compose.override.yml",
"compose.override.yaml",
"docker-compose.override.yml",
"docker-compose.override.yaml",
)
_ENV_LINE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$")
_VAR_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)")
def _dotenv(directory: str) -> dict[str, str]:
"""The folder's ``.env`` as compose reads it for its own settings."""
values: dict[str, str] = {}
try:
with open(os.path.join(directory, ".env"), encoding="utf-8", errors="replace") as fh:
for raw in fh:
if raw.lstrip().startswith("#"):
continue
m = _ENV_LINE.match(raw)
if not m:
continue
value = m.group(2)
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
values[m.group(1)] = value
except OSError:
pass
return values
def compose_files(directory: str, env: Optional[dict] = None) -> list[str]:
"""The files ``docker compose`` loads in this folder, in order.
``COMPOSE_FILE`` from the folder's ``.env`` if set, otherwise the first of
the default names plus an override file next to it — what running
``docker compose`` in the folder picks.
"""
return _PROJECT_INVALID_RE.sub("", stack_id.lower()).lstrip("_-") or "stack"
env = _dotenv(directory) if env is None else env
if env.get("COMPOSE_FILE"):
sep = env.get("COMPOSE_PATH_SEPARATOR") or os.pathsep
return [
os.path.normpath(os.path.join(directory, part))
for part in env["COMPOSE_FILE"].split(sep)
if part.strip()
]
files = []
main = find_compose_file(directory)
if main:
files.append(main)
for name in COMPOSE_OVERRIDE_FILENAMES:
candidate = os.path.join(directory, name)
if os.path.isfile(candidate):
files.append(candidate)
break
return files
#: directory -> (fingerprint of the files that decide it, declared name)
_DECLARED: dict[str, tuple[tuple, Optional[str]]] = {}
def _fingerprint(paths) -> tuple:
out = []
for path in paths:
try:
st = os.stat(path)
out.append((path, st.st_mtime_ns, st.st_size))
except OSError:
out.append((path, None, None))
return tuple(out)
def declared_project(directory: str) -> Optional[str]:
"""The project name compose takes from the folder itself, if it sets one:
``COMPOSE_PROJECT_NAME`` in ``.env``, else the last ``name:`` among its
compose files (``${VAR}`` resolved from ``.env``). Cached until one of
those files changes."""
candidates = [os.path.join(directory, ".env")]
candidates += [os.path.join(directory, n) for n in COMPOSE_FILENAMES + COMPOSE_OVERRIDE_FILENAMES]
env = _dotenv(directory)
files = compose_files(directory, env)
key = _fingerprint(candidates + [f for f in files if f not in candidates])
cached = _DECLARED.get(directory)
if cached and cached[0] == key:
return cached[1]
name: Optional[str] = None
if env.get("COMPOSE_PROJECT_NAME"):
name = env["COMPOSE_PROJECT_NAME"]
else:
import yaml
for path in files:
try:
with open(path, encoding="utf-8") as fh:
doc = yaml.safe_load(fh)
except (OSError, yaml.YAMLError):
continue
if isinstance(doc, dict) and isinstance(doc.get("name"), str) and doc["name"].strip():
name = _VAR_REF.sub(
lambda m: env.get(m.group(1) or m.group(3)) or (m.group(2) or ""),
doc["name"].strip(),
)
declared = _normalize_project(name) if name else None
_DECLARED[directory] = (key, declared or None)
return declared or None
#: project -> (monotonic time, Docker event generation, has containers or volumes)
_RESOURCES: dict[str, tuple[float, int, bool]] = {}
_RESOURCES_TTL = 15.0
def _has_resources(project: str) -> bool:
import time
from services import docker_watch_service
gen = docker_watch_service.generation()
hit = _RESOURCES.get(project)
if hit and hit[1] == gen and time.monotonic() - hit[0] < _RESOURCES_TTL:
return hit[2]
label = {"label": f"{COMPOSE_LABEL}={project}"}
try:
client = get_client()
found = bool(safe_call(client.containers.list, all=True, filters=label)) or bool(
safe_call(client.volumes.list, filters=label)
)
except DockerError:
found = False
_RESOURCES[project] = (time.monotonic(), gen, found)
return found
def project_name(stack_id: str) -> str:
"""The compose project name for a stack — the one ``docker compose`` uses
when it is run in the stack's folder.
That is ``COMPOSE_PROJECT_NAME`` from the folder's ``.env``, else the
compose file's ``name:``, else the folder name, normalised the way compose
does it (lowercase, only ``a-z0-9_-``, no leading ``-``/``_``).
Before 0.67.2 StackPilot always used the folder name. A stack whose file
says ``name: immich`` in a folder called ``photos`` therefore ran as two
different projects: started in a terminal StackPilot did not see it, and
its Start button set up a second copy next to it. A stack StackPilot
started that old way keeps its folder-derived name for as long as it has
containers or volumes under it and none under the declared one — switching
would leave them running and bring up the stack again with empty volumes.
"""
legacy = _normalize_project(stack_id) or "stack"
declared = declared_project(stack_dir(stack_id))
if not declared or declared == legacy:
return legacy
if _has_resources(legacy) and not _has_resources(declared):
return legacy
return declared
def stack_ids_by_project(stack_ids) -> dict[str, str]:
@@ -114,6 +264,18 @@ def read_compose(stack_id: str, override: Optional[str] = None) -> str:
return fh.read()
def stack_revision(stack_id: str, override: Optional[str] = None) -> str:
"""A short fingerprint of the compose file and ``.env`` as they are on
disk: what the editor loaded, compared again when it saves."""
import hashlib
digest = hashlib.sha256()
digest.update(read_compose(stack_id, override).encode())
digest.update(b"\0")
digest.update(read_env(stack_id, override).encode())
return digest.hexdigest()[:16]
def read_env(stack_id: str, override: Optional[str] = None) -> str:
path = env_path(stack_id, override)
if not os.path.isfile(path):
@@ -450,8 +612,15 @@ def stack_status_summaries() -> dict[str, dict]:
def _compose_base_cmd(stack_id: str, override: Optional[str] = None) -> list[str]:
"""``docker compose`` as it runs when typed in the stack's folder.
No ``-f``: with it, compose loaded that one file only — a
``compose.override.yaml`` or a ``COMPOSE_FILE`` in ``.env`` applied in a
terminal and was dropped again by the next Start in StackPilot, which
recreated the containers without it. The subprocess runs in the folder
(:func:`_compose_cwd`), which is where ``COMPOSE_FILE`` paths are read from.
"""
directory = stack_dir(stack_id, override)
compose_file = find_compose_file(directory) or os.path.join(directory, DEFAULT_COMPOSE_NAME)
return [
"docker",
"compose",
@@ -459,11 +628,14 @@ def _compose_base_cmd(stack_id: str, override: Optional[str] = None) -> list[str
project_name(stack_id),
"--project-directory",
directory,
"-f",
compose_file,
]
def _compose_cwd(stack_id: str, override: Optional[str] = None) -> Optional[str]:
directory = stack_dir(stack_id, override)
return directory if os.path.isdir(directory) else None
async def run_compose(
stack_id: str,
args: list[str],
@@ -475,6 +647,7 @@ async def run_compose(
proc = await asyncio.create_subprocess_exec(
*cmd,
env=registry_service.cli_env(),
cwd=_compose_cwd(stack_id, override),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -559,6 +732,7 @@ async def stream_compose(
proc = await asyncio.create_subprocess_exec(
*cmd,
env=registry_service.cli_env(),
cwd=_compose_cwd(stack_id, override),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
@@ -621,6 +795,7 @@ async def _stream_phase(
proc = await asyncio.create_subprocess_exec(
*cmd,
env=registry_service.cli_env(),
cwd=_compose_cwd(stack_id, override),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)