261 lines
12 KiB
Python
261 lines
12 KiB
Python
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 <files> 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 <files> 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.<svc>.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: <project>` 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 <global.env> <stack.env>
|
|
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 <env-file> — 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 <global.env> <stack.env> — 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 <global.env> <stack.env>
|
|
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)
|