From 162700bb4f80211c1ddee953a3c49d13af15fdf2 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 26 Aug 2026 23:26:32 -0700 Subject: [PATCH] Fix: stack-deploy.sh now picks export_raw/export_raw_merged for folder+extras stacks (docker compose config path) vs export/export_merged for single-file stacks, matching envparse.py's dual-escaping fix --- deploy/stack-deploy.sh | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/deploy/stack-deploy.sh b/deploy/stack-deploy.sh index 9ebc571..17ffda8 100755 --- a/deploy/stack-deploy.sh +++ b/deploy/stack-deploy.sh @@ -28,6 +28,16 @@ # source path exists, and flags suspicious-looking empty Postgres data # dirs, before anything touches Swarm. # 3. docker stack deploy -c +# +# Dollar-escaping (2026-08-26, see envparse.py comments for full detail): +# - Single-file path (no extras): envsubst does no '$' escaping of its +# own, and only Swarm's `docker stack deploy` interpolation pass runs +# downstream -> use export/export_merged (escapes '$' -> '$$' once). +# - Folder+extras path: `docker compose config` ALSO does its own '$' +# escaping on top of Swarm's -> use export_raw/export_raw_merged (no +# pre-escaping) or values get doubled twice. Getting this wrong +# silently corrupts any secret/hash containing '$' (confirmed impact: +# LITELLM keys truncated, IMMICH_KIOSK_BASICAUTH bcrypt hash mismatched). set -euo pipefail @@ -94,6 +104,12 @@ for f in "${EXTRAS[@]:-}"; do [ -n "$f" ] && F_FLAGS+=(-f "$f") done +# Whether the render will go through `docker compose config` (folder mode +# with 1+ extras). This determines which escaping mode is correct — see +# header comment and envparse.py for why these must differ. +USES_COMPOSE_CONFIG=0 +[ "${#F_FLAGS[@]}" -gt 2 ] && USES_COMPOSE_CONFIG=1 + # ── Load env (global base + optional stack override) ───────────────────────── HINT=" Hint: 'secret not found' means Woodpecker hasn't provisioned secrets yet.\n Trigger the pipeline: https://woodpecker.bryanmail.net\n" @@ -103,19 +119,25 @@ STACK_EXISTS=0 [ -f "$GLOBAL_ENV" ] && GLOBAL_EXISTS=1 [ -f "$ENVFILE" ] && STACK_EXISTS=1 +if [ "$USES_COMPOSE_CONFIG" -eq 1 ]; then + EXPORT_MODE="export_raw"; EXPORT_MERGED_MODE="export_raw_merged" +else + EXPORT_MODE="export"; EXPORT_MERGED_MODE="export_merged" +fi + if [ "$GLOBAL_EXISTS" -eq 1 ] && [ "$STACK_EXISTS" -eq 1 ]; then - echo " Env: $GLOBAL_ENV + $ENVFILE (stack overrides global)" - eval "$(python3 "$PY" export_merged "$GLOBAL_ENV" "$ENVFILE")" + echo " Env: $GLOBAL_ENV + $ENVFILE (stack overrides global) [$EXPORT_MERGED_MODE]" + eval "$(python3 "$PY" "$EXPORT_MERGED_MODE" "$GLOBAL_ENV" "$ENVFILE")" VARS="$(python3 "$PY" vars_merged "$GLOBAL_ENV" "$ENVFILE")" elif [ "$GLOBAL_EXISTS" -eq 1 ]; then - echo " Env: $GLOBAL_ENV (no stack env)" - eval "$(python3 "$PY" export "$GLOBAL_ENV")" + echo " Env: $GLOBAL_ENV (no stack env) [$EXPORT_MODE]" + eval "$(python3 "$PY" "$EXPORT_MODE" "$GLOBAL_ENV")" VARS="$(python3 "$PY" vars "$GLOBAL_ENV")" elif [ "$STACK_EXISTS" -eq 1 ]; then - echo " Env: $ENVFILE (no global env)" - eval "$(python3 "$PY" export "$ENVFILE")" + echo " Env: $ENVFILE (no global env) [$EXPORT_MODE]" + eval "$(python3 "$PY" "$EXPORT_MODE" "$ENVFILE")" VARS="$(python3 "$PY" vars "$ENVFILE")" else @@ -131,7 +153,7 @@ RENDERED="$(mktemp /tmp/stack-deploy.XXXXXX.yml)" trap 'rm -f "$RENDERED"' EXIT if [ -n "$VARS" ]; then - if [ "${#F_FLAGS[@]}" -gt 2 ]; then + if [ "$USES_COMPOSE_CONFIG" -eq 1 ]; then # Folder mode with extras: merge via docker compose config docker compose "${F_FLAGS[@]}" config \ | python3 "$PY" strip \ @@ -144,7 +166,7 @@ if [ -n "$VARS" ]; then > "$RENDERED" fi else - if [ "${#F_FLAGS[@]}" -gt 2 ]; then + if [ "$USES_COMPOSE_CONFIG" -eq 1 ]; then docker compose "${F_FLAGS[@]}" config \ | python3 "$PY" strip \ > "$RENDERED"