diff --git a/deploy/envparse.py b/deploy/envparse.py index 06e1946..c238063 100644 --- a/deploy/envparse.py +++ b/deploy/envparse.py @@ -156,9 +156,6 @@ def collapse_depends_on(text): if not parse_ok or not services: # Re-emit whatever we consumed as-is (best effort: fall back to # original slice) rather than risk mangling an unfamiliar shape. - # Rebuild by re-scanning is avoided here; instead just continue - # emitting the raw lines we already stepped over. - # (This branch is rare: only hit on unexpected compose output.) pass for svc in services: @@ -166,6 +163,36 @@ def collapse_depends_on(text): return '\n'.join(out) +# ────────────────────────────────────────────────────────────────────────── +# 2026-08-26 FIX — top-level `name:` property rejected by Swarm: +# +# `docker compose config` (Compose Spec output) emits a top-level +# `name: ` key as the first line of the rendered document, e.g.: +# name: immich +# services: +# ... +# +# This is valid Compose Spec but `docker stack deploy` (Swarm mode) uses +# a stricter schema that does NOT allow a top-level `name` property, and +# rejects the whole document with: "(root) Additional property name is +# not allowed". Single-file stacks (no extras) never hit this because +# they skip `docker compose config` and go straight through envsubst on +# the raw source YAML, which never had a top-level `name:` to begin with. +# +# Fix: drop ONLY the top-level `name:` line (column 0, i.e. no leading +# whitespace) at the START of the document. Nested `name:` fields under +# networks/volumes/secrets (e.g. `name: postgresql_db-backend`, always +# indented) are legitimate and must NOT be touched. +# ────────────────────────────────────────────────────────────────────────── + +_TOP_NAME_RE = re.compile(r'^name:\s*\S+\s*$') + +def strip_top_level_name(text): + lines = text.split('\n') + if lines and _TOP_NAME_RE.match(lines[0]): + lines = lines[1:] + return '\n'.join(lines) + mode = sys.argv[1] if mode == 'export': for k, v in parse_env(sys.argv[2]): @@ -183,4 +210,5 @@ elif mode == 'strip': t = sys.stdin.read() t = re.sub(r'[ \t]*env_file:[ \t]*\n([ \t]+-[^\n]*\n)+', '', t) t = collapse_depends_on(t) + t = strip_top_level_name(t) sys.stdout.write(t)