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
This commit is contained in:
1 parent
d6cebb3371
commit
06234c1067
21 files changed
+811
-45
No files matched your search
@@ -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,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user