Files
compose-files/deploy/envparse.py
T

215 lines
9.4 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 applied in export/export_merged (which feed `eval` to set the
# actual values envsubst reads) — NOT in vars/vars_merged, which just
# build envsubst's space-separated $VARNAME allowlist string and have
# nothing to do with actual values.
# ──────────────────────────────────────────────────────────────────────────
def escape_dollar(v):
return v.replace('$', '$$')
# ──────────────────────────────────────────────────────────────────────────
# 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 == '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)