import sys, re def parse_env(path): pairs = [] with open(path) as f: for line in f: line = line.strip() if not line or line.startswith('#') or '=' not in line: continue k, _, v = line.partition('=') k = k.strip(); v = v.strip() if (v.startswith("'") and v.endswith("'")) or \ (v.startswith('"') and v.endswith('"')): v = v[1:-1] pairs.append((k, v)) return pairs def merge_envs(base_path, override_path): """Merge two env files. Keys in override_path win over base_path.""" base = dict(parse_env(base_path)) override = dict(parse_env(override_path)) merged = {**base, **override} return list(merged.items()) # ────────────────────────────────────────────────────────────────────────── # 2026-08-26 FIX — double-interpolation truncation bug (Pattern B stacks): # # stack-deploy.sh's single-file/no-extras path is: # envsubst "$VARS" < ai.yaml | docker stack deploy -c - ai # # envsubst substitutes ${VAR} placeholders in the compose YAML with the # literal, raw value of each shell-exported variable. If that raw value # itself contains a literal '$' followed by word characters (e.g. a # randomly-generated secret like "...i*Edu$RyAVYTqr4yzSS##..."), the # resulting YAML text now contains what LOOKS like a second variable # reference. `docker stack deploy -c -` runs Compose's own interpolation # pass on that YAML text before creating the service — and Compose sees # that leftover "$RyAVYTqr4yzSS", finds no such env var, and silently # substitutes empty string. The secret gets truncated in the running # container with NO error or warning. # # Confirmed impact (2026-08-26): LITELLM_MASTER_KEY and LITELLM_SALT_KEY # in the `ai` stack were both truncated at their first literal '$' after # a real deploy — 87-char secret arrived in the container as 73 chars. # # This affects every stack using Pattern B (host .env + envsubst, not # native Docker secrets): ai, maintenance, media, unifi, guacamole, # security, auth, traefik, meshcentral, ddm — any of them could have a # '$'-containing value silently truncating right now without detection, # since the failure is silent and only visible by diffing the source # value against the live container env. # # Fix: escape every literal '$' in a value as '$$' at export time, BEFORE # envsubst ever sees it. envsubst does not interpret '$' in the # replacement text (only in the template), so the doubled dollar survives # envsubst untouched. Compose's interpolation pass then consumes exactly # one level of escaping ('$$' -> literal '$'), landing on the correct # original single '$' with no leftover variable-reference lookalike. # # ONLY applies to the single-file path (no `docker compose config` step # downstream). See export_raw/export_raw_merged below for why the # folder+extras path must NOT use this. # ────────────────────────────────────────────────────────────────────────── def escape_dollar(v): return v.replace('$', '$$') # ────────────────────────────────────────────────────────────────────────── # 2026-08-26 FIX #2 — SECOND double-interpolation bug, folder+extras path # (discovered fixing IMMICH_KIOSK_BASICAUTH, a bcrypt hash full of '$'): # # The folder+extras render path is: # docker compose config | strip | envsubst "$VARS" # -> docker stack deploy -c - # # `docker compose config` performs its OWN ${VAR} interpolation AND its # own re-escaping of the output: any literal '$' character that ends up # in the rendered YAML — escaped or not — gets doubled to '$$' by # `docker compose config` itself, unconditionally, as part of producing # spec-safe output. Confirmed by isolated test: # raw MYVAR='a$b$c' -> docker compose config -> "a$$b$$c" (correct, # one level added) # escaped MYVAR='a$$b$$c' (i.e. pre-doubled by escape_dollar) -> # docker compose config -> "a$$$$b$$$$c" (WRONG, doubled twice) # # `docker stack deploy -c -` still only removes exactly ONE level of # escaping on its way in (confirmed: "a$$b$$c" -> container label # "a$b$c", correct). So across the whole folder+extras pipeline there is # exactly ONE implicit escaping step (`docker compose config`) and ONE # implicit un-escaping step (`docker stack deploy`) already built in — # pre-escaping the exported value on top of that leaves one extra, # uncollapsed level of '$$' in the final container label/env value. # # Confirmed impact (2026-08-26): IMMICH_KIOSK_BASICAUTH # ("BabyBryan:$2y$05$...") rendered as "BabyBryan:$$2y$$05$$..." in the # final container label — Traefik basic auth would never match the real # password hash, silently locking out the kiosk with no error. # # Fix: use export_raw / export_raw_merged (NO escape_dollar) whenever the # render path goes through `docker compose config` — i.e. any stack with # extension files. Use export / export_merged (WITH escape_dollar) only # for the single-file path, which has no `docker compose config` step and # therefore only Swarm's own interpolation pass to protect against. # ────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────── # 2026-08-26 FIX — depends_on long-form vs Swarm short-form: # # Folder-based stacks with extension files (e.g. immich + hwaccel.*.yml) # go through the `docker compose config | strip | docker stack # deploy -c -` merge path. Modern `docker compose config` normalizes the # short-form list syntax: # depends_on: [database, redis] # into the long-form condition mapping: # depends_on: # database: # condition: service_started # required: true # redis: # condition: service_started # required: true # # `docker stack deploy` (Swarm mode) does NOT understand the long-form # mapping and rejects it with: "services..depends_on must be a list". # Single-file stacks (no extras) never hit this because they skip the # `docker compose config` step entirely and go straight through envsubst. # # Fix: collapse any long-form depends_on mapping back into the Swarm- # compatible short-form list, purely as a text transform on the rendered # YAML, right alongside the existing env_file stripping. # ────────────────────────────────────────────────────────────────────────── _DEP_RE = re.compile(r'^([ \t]*)depends_on:\s*$') _CHILD_KEY_RE = re.compile(r'^([ \t]*)([\w.\-]+):\s*$') _CHILD_LIST_RE = re.compile(r'^([ \t]*)-\s*(\S+)\s*$') def collapse_depends_on(text): lines = text.split('\n') out = [] i = 0 n = len(lines) while i < n: line = lines[i] m = _DEP_RE.match(line) if not m: out.append(line) i += 1 continue indent = m.group(1) base_indent = len(indent) out.append(line) i += 1 services = [] parse_ok = True while i < n: l = lines[i] if l.strip() == '': i += 1 continue cur_indent = len(l) - len(l.lstrip(' \t')) if cur_indent <= base_indent: break # dedent — end of this depends_on block lm = _CHILD_LIST_RE.match(l) if lm and cur_indent == base_indent + 2: services.append(lm.group(2)) i += 1 continue km = _CHILD_KEY_RE.match(l) if km and cur_indent == base_indent + 2: services.append(km.group(2)) i += 1 # skip nested condition/required/restart lines under this service while i < n: nl = lines[i] if nl.strip() == '': i += 1 continue nl_indent = len(nl) - len(nl.lstrip(' \t')) if nl_indent > base_indent + 2: i += 1 continue break continue # Unexpected shape — bail without transforming to avoid corrupting output parse_ok = False break 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. pass for svc in services: out.append(indent + ' - ' + svc) 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]): print('export {}={}'.format(k, repr(escape_dollar(v)))) elif mode == 'export_merged': # export_merged for k, v in merge_envs(sys.argv[2], sys.argv[3]): print('export {}={}'.format(k, repr(escape_dollar(v)))) elif mode == 'export_raw': # export_raw — NO escape_dollar. Use for the docker-compose- # config render path (folder+extras), which does its own '$' escaping. for k, v in parse_env(sys.argv[2]): print('export {}={}'.format(k, repr(v))) elif mode == 'export_raw_merged': # export_raw_merged — NO escape_dollar. for k, v in merge_envs(sys.argv[2], sys.argv[3]): print('export {}={}'.format(k, repr(v))) elif mode == 'vars': print(' '.join('$' + k for k, v in parse_env(sys.argv[2]))) elif mode == 'vars_merged': # vars_merged print(' '.join('$' + k for k, v in merge_envs(sys.argv[2], sys.argv[3]))) 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)