Author SHA1 Message Date
admin d209fc3222 fix: escape literal $ in .env values before envsubst (Pattern B stacks)
Root cause of the LITELLM_MASTER_KEY/LITELLM_SALT_KEY truncation
incident (2026-08-26): stack-deploy.sh's single-file deploy path is
`envsubst "$VARS" < stack.yaml | docker stack deploy -c - stack`.
envsubst embeds the raw secret value into the compose YAML text. If
that value contains a literal '$' followed by word chars, the
resulting YAML now contains what looks like a second variable
reference. `docker stack deploy -c -` runs Compose's own interpolation
pass on that text before creating the service, finds no such env var,
and silently substitutes empty string -- truncating the secret in the
running container with no error.

Confirmed: an 87-char LITELLM_MASTER_KEY arrived in the ai_litellm
container as 73 chars, silently, on a real deploy.

This is not specific to ai -- it affects every Pattern B stack (host
.env + envsubst, not native Docker secrets): maintenance, media,
unifi, guacamole, security, auth, traefik, meshcentral, ddm. Any of
them could have a '$'-containing value truncating right now without
detection, since the failure produces no warning.

Fix: escape every literal '$' as '$$' in export/export_merged (which
feed the `eval` that sets envsubst's actual source values), before
envsubst ever sees them. envsubst does not interpret '$' in replacement
text, so the doubled dollar survives envsubst intact; Compose's own
interpolation pass then consumes exactly one level of escaping,
landing on the correct single '$' with no leftover false variable
reference. vars/vars_merged (envsubst's allowlist string, unrelated to
values) are untouched.

NOT deployed/merged yet -- pending review. The currently-running
ai_litellm service still has the truncated keys and needs a fresh
`stack-deploy.sh ai` run after this merges to pick up the corrected
values.
2026-08-26 20:56:23 -07:00
admin d7fe56f6fe ai: add doc comment noting secrets are now provisioned via Woodpecker
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
No functional change -- this comment-only edit exists to trigger a real
deploy of the ai stack so provision-secrets' ai) case, and the AI_
var-name fix from PR #7, get exercised end-to-end for the first time.

Documents that MCPO_API_KEY is intentionally still manual/unmigrated.
2026-08-25 23:48:47 -07:00
AVB d1986678bc Merge pull request 'fix(ai): use AI_-prefixed AWS key names in ai) provisioning case' (#7) from fix-ai-aws-var-names into main
ci/woodpecker/push/deploy Pipeline was successful
Reviewed-on: #7
2026-08-25 23:45:27 -07:00
admin 6af2633936 fix(ai): write AI_-prefixed AWS key names to ai.env, not plain names
ai.yaml's litellm service (as of commit 37ed671a, "Change AWS keys to
use Woodpecker Secrets") references ${AI_AWS_ACCESS_KEY_ID} /
${AI_AWS_SECRET_ACCESS_KEY} and renders them into the container as
plain AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY. The ai) provisioning
case added in the earlier secrets-migration PR wrote the plain
(unprefixed) names into ai.env instead, which would leave
${AI_AWS_ACCESS_KEY_ID} unresolved at compose-render time (renders
empty) -- silently breaking Bedrock auth in litellm on the next ai
stack deploy.

Fixed both the grep -vE exclusion pattern and the two printf lines to
use the AI_-prefixed names. All other migrated vars in ai.yaml use
plain names and are unaffected.

No other changes in this file.
2026-08-25 23:42:39 -07:00
AVB 27df7cf58f Merge pull request 'HOTFIX: restore $${VAR} escaping dropped by AI secrets migration rewrite' (#6) from hotfix-dollar-escaping into main
ci/woodpecker/push/deploy Pipeline was successful
Reviewed-on: #6
2026-08-25 23:34:08 -07:00
admin 5188aef250 hotfix: restore $${VAR} double-dollar escaping for all secret-backed vars
The AI secrets migration PR (#4) was a full-file rewrite of deploy.yml.
That rewrite mechanically dropped one $ from EVERY $${VAR} occurrence in
the file, not just the new AI additions -- silently reverting all
pre-existing secret references (SWARM_MANAGER_IP, IMMICH_*, GIT_*,
POSTGRESQL_*, VAULTWARDEN_*, ENTERTAINMENT_*, etc.) to single-dollar
form. Per this file's own header comment, Woodpecker blanks single-dollar
braced refs at compile time since secrets aren't in that variable map --
this is the exact "SWARM_MANAGER_IP secret is empty" failure mode
documented above, and it fired immediately on the first push after #4
merged.

Impact: provision-secrets/deploy/verify all failed at their first
if-empty guard and exited before any ssh/scp/rsync ran. No live secret,
service, or deployed stack was touched -- this was a CI-only outage.

Fix: restored $${VAR} for every secret-backed reference throughout the
file. CI_PIPELINE_FILES / CI_COMMIT_MESSAGE stay single-dollar (correct
-- those are Woodpecker compile-time metadata, not secrets). The \$FILE
/ \$TMP backslash-escaping inside the ai) case's remote SSH command is
unrelated and was already correct (it protects those local-to-remote
vars from expanding before the SSH payload is sent).

This is a straight revert-of-the-regression -- no new secrets, no logic
changes beyond restoring the escaping.
2026-08-25 23:27:47 -07:00
admin d4797d4b4b docs: note ai stack secrets in SECRETS.md (trivial commit to force clean pipeline run)
ci/woodpecker/push/deploy Pipeline failed
No stack files changed -- this commit exists only to trigger a fresh
Woodpecker pipeline run against current main + current secrets, since
"Restart" on the prior failed run was replaying a stale snapshot from
before the ai_* secrets existed.
2026-08-25 23:19:14 -07:00
4 changed files with 171 additions and 73 deletions
+114 -70
View File
@@ -26,6 +26,16 @@ when:
# This was the root cause of a long-running "SWARM_MANAGER_IP secret is # This was the root cause of a long-running "SWARM_MANAGER_IP secret is
# empty" failure: braced references were blanked at compile time before the # empty" failure: braced references were blanked at compile time before the
# shell ever saw them. # shell ever saw them.
#
# 2026-08-26 HOTFIX: a full-file rewrite (AI secrets migration PR) dropped
# one $ from every $${VAR} occurrence throughout this file, re-introducing
# exactly the bug described above for EVERY secret-backed var, not just the
# new AI ones. The if-empty guards caught it immediately (SWARM_MANAGER_IP
# came back blank) and aborted before any ssh/scp/rsync ran, so no live
# secret or service was touched — but no CI provisioning/deploy could run
# until this was restored. Lesson: grep for the literal string '$${' and
# diff the count against the previous version before ever committing a
# full-file rewrite of this pipeline.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
steps: steps:
@@ -162,18 +172,18 @@ steps:
- echo "$SSH_KEY" | base64 -d > ~/.ssh/id_rsa - echo "$SSH_KEY" | base64 -d > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa
- | - |
if [ -z "${SWARM_MANAGER_IP}" ]; then if [ -z "$${SWARM_MANAGER_IP}" ]; then
echo "ERROR: SWARM_MANAGER_IP secret is empty. Check Woodpecker repo secrets." echo "ERROR: SWARM_MANAGER_IP secret is empty. Check Woodpecker repo secrets."
exit 1 exit 1
fi fi
- ssh-keyscan -H ${SWARM_MANAGER_IP} >> ~/.ssh/known_hosts 2>/dev/null || true - ssh-keyscan -H $${SWARM_MANAGER_IP} >> ~/.ssh/known_hosts 2>/dev/null || true
- | - |
CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n') CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n')
FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true) FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true)
FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | sort -u || true) FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | sort -u || true)
ALL_STACKS=$(printf '%s\n%s' "$FLAT_STACKS" "$FOLDER_STACKS" | grep -v '^$' | sort -u) ALL_STACKS=$(printf '%s\n%s' "$FLAT_STACKS" "$FOLDER_STACKS" | grep -v '^$' | sort -u)
[ -z "$ALL_STACKS" ] && echo "No stacks changed, skipping" && exit 0 [ -z "$ALL_STACKS" ] && echo "No stacks changed, skipping" && exit 0
- scp -o StrictHostKeyChecking=no deploy/create-secrets.sh root@${SWARM_MANAGER_IP}:/tmp/cs.sh - scp -o StrictHostKeyChecking=no deploy/create-secrets.sh root@$${SWARM_MANAGER_IP}:/tmp/cs.sh
- | - |
CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n') CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n')
FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true) FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true)
@@ -186,92 +196,102 @@ steps:
maintenance|media|unifi|guacamole|security|auth|traefik|meshcentral|ddm) maintenance|media|unifi|guacamole|security|auth|traefik|meshcentral|ddm)
echo " No Docker secrets for $STACK — secrets in host .env";; echo " No Docker secrets for $STACK — secrets in host .env";;
immich) immich)
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "source /tmp/cs.sh ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'immich_db_password' '${IMMICH_DB_PASSWORD}' create_or_update_secret 'immich_db_password' '$${IMMICH_DB_PASSWORD}'
create_or_update_secret 'immich_kiosk_basicauth' '${IMMICH_KIOSK_BASICAUTH}'";; create_or_update_secret 'immich_kiosk_basicauth' '$${IMMICH_KIOSK_BASICAUTH}'";;
woodpecker) woodpecker)
echo " Manual only — skipping";; echo " Manual only — skipping";;
3dprint) 3dprint)
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "source /tmp/cs.sh ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret '3dprint_db_password' '${PRINT3D_DB_PASSWORD}'";; create_or_update_secret '3dprint_db_password' '$${PRINT3D_DB_PASSWORD}'";;
gamma) gamma)
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "source /tmp/cs.sh ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'gamma_auth_token' '${GAMMA_AUTH_TOKEN}'";; create_or_update_secret 'gamma_auth_token' '$${GAMMA_AUTH_TOKEN}'";;
git) git)
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "source /tmp/cs.sh ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'git_db_password' '${GIT_DB_PASSWORD}' create_or_update_secret 'git_db_password' '$${GIT_DB_PASSWORD}'
create_or_update_secret 'git_runner_token' '${GIT_RUNNER_TOKEN}' create_or_update_secret 'git_runner_token' '$${GIT_RUNNER_TOKEN}'
create_or_update_secret 'git_mcp_access_token' '${GIT_MCP_ACCESS_TOKEN}'";; create_or_update_secret 'git_mcp_access_token' '$${GIT_MCP_ACCESS_TOKEN}'";;
homeassistant) homeassistant)
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "source /tmp/cs.sh ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'homeassistant_frigate_rtsp_password' '${HOMEASSISTANT_FRIGATE_RTSP_PASSWORD}' create_or_update_secret 'homeassistant_frigate_rtsp_password' '$${HOMEASSISTANT_FRIGATE_RTSP_PASSWORD}'
create_or_update_secret 'homeassistant_immich_api_key' '${HOMEASSISTANT_IMMICH_API_KEY}'";; create_or_update_secret 'homeassistant_immich_api_key' '$${HOMEASSISTANT_IMMICH_API_KEY}'";;
mealie) mealie)
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "source /tmp/cs.sh ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'mealie_db_password' '${MEALIE_DB_PASSWORD}' create_or_update_secret 'mealie_db_password' '$${MEALIE_DB_PASSWORD}'
create_or_update_secret 'mealie_ldap_query_password' '${MEALIE_LDAP_QUERY_PASSWORD}'";; create_or_update_secret 'mealie_ldap_query_password' '$${MEALIE_LDAP_QUERY_PASSWORD}'";;
n8n) n8n)
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "source /tmp/cs.sh ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'n8n_db_password' '${N8N_DB_PASSWORD}' create_or_update_secret 'n8n_db_password' '$${N8N_DB_PASSWORD}'
create_or_update_secret 'n8n_encryption_key' '${N8N_ENCRYPTION_KEY}'";; create_or_update_secret 'n8n_encryption_key' '$${N8N_ENCRYPTION_KEY}'";;
postgresql) postgresql)
# NOTE: bootstrap-tier stack (manual deploy only via stack-deploy.sh). # NOTE: bootstrap-tier stack (manual deploy only via stack-deploy.sh).
# Secrets are still auto-provisioned here so they exist on the host # Secrets are still auto-provisioned here so they exist on the host
# before the manual `stack-deploy.sh postgresql` run picks them up. # before the manual `stack-deploy.sh postgresql` run picks them up.
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "source /tmp/cs.sh ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'postgresql_password' '${POSTGRESQL_PASSWORD}' create_or_update_secret 'postgresql_password' '$${POSTGRESQL_PASSWORD}'
create_or_update_secret 'postgresql_pgadmin_password' '${POSTGRESQL_PGADMIN_PASSWORD}' create_or_update_secret 'postgresql_pgadmin_password' '$${POSTGRESQL_PGADMIN_PASSWORD}'
create_or_update_secret 'postgresql_replication_password' '${POSTGRESQL_REPLICATION_PASSWORD}' create_or_update_secret 'postgresql_replication_password' '$${POSTGRESQL_REPLICATION_PASSWORD}'
create_or_update_secret 'postgresql_patroni_password' '${POSTGRESQL_PATRONI_PASSWORD}'";; create_or_update_secret 'postgresql_patroni_password' '$${POSTGRESQL_PATRONI_PASSWORD}'";;
productivity) productivity)
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "source /tmp/cs.sh ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'productivity_paperless_secret_key' '${PRODUCTIVITY_PAPERLESS_SECRET_KEY}' create_or_update_secret 'productivity_paperless_secret_key' '$${PRODUCTIVITY_PAPERLESS_SECRET_KEY}'
create_or_update_secret 'productivity_db_password' '${PRODUCTIVITY_DB_PASSWORD}' create_or_update_secret 'productivity_db_password' '$${PRODUCTIVITY_DB_PASSWORD}'
create_or_update_secret 'productivity_oidc_providers' '${PRODUCTIVITY_OIDC_PROVIDERS}'";; create_or_update_secret 'productivity_oidc_providers' '$${PRODUCTIVITY_OIDC_PROVIDERS}'";;
vaultwarden) vaultwarden)
# NOTE: vaultwarden_database_url cannot be rotated in-place — Swarm refuses # NOTE: vaultwarden_database_url cannot be rotated in-place — Swarm refuses
# to remove a secret referenced by a running service's spec. We provision # to remove a secret referenced by a running service's spec. We provision
# under a versioned name instead; vaultwarden.yaml maps it back to the same # under a versioned name instead; vaultwarden.yaml maps it back to the same
# in-container filename via target. The old secret is removed manually once # in-container filename via target. The old secret is removed manually once
# the compose file cutover is confirmed healthy. # the compose file cutover is confirmed healthy.
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "source /tmp/cs.sh ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'vaultwarden_admin_token' '${VAULTWARDEN_ADMIN_TOKEN}' create_or_update_secret 'vaultwarden_admin_token' '$${VAULTWARDEN_ADMIN_TOKEN}'
create_or_update_secret 'vaultwarden_database_url_v2' '${VAULTWARDEN_DATABASE_URL}'";; create_or_update_secret 'vaultwarden_database_url_v2' '$${VAULTWARDEN_DATABASE_URL}'";;
ai) ai)
# NOTE: ai stack uses Pattern B (host .env, not native Docker secrets) — # NOTE: ai stack uses Pattern B (host .env, not native Docker secrets) —
# LiteLLM/boto3 and Open WebUI don't support the _FILE convention for these # LiteLLM/boto3 and Open WebUI don't support the _FILE convention for these
# vars. Instead of Docker secrets, we regenerate ONLY the migrated lines in # vars. Instead of Docker secrets, we regenerate ONLY the migrated lines in
# the remote ai/ai.env in place via grep -v + printf (never sed, since values # the remote ai/ai.env in place via grep -v + printf (never sed, since values
# may contain '/', '$', '&'). All other lines — including MCPO_API_KEY, # may contain slash, dollar sign, ampersand). All other lines — including
# OAUTH_CLIENT_ID, WEBUI_URL, and other non-secret config — are left # MCPO_API_KEY, OAUTH_CLIENT_ID, WEBUI_URL, and other non-secret config — are
# completely untouched. MCPO_API_KEY migration is deferred to a follow-up; # left completely untouched. MCPO_API_KEY migration is deferred to a
# this step never reads or writes it. # follow-up; this step never reads or writes it.
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "FILE=/volume1/docker/compose-files/ai/ai.env #
# IMPORTANT: ai.yaml's litellm service references ${AI_AWS_ACCESS_KEY_ID} /
# ${AI_AWS_SECRET_ACCESS_KEY} (AI_-prefixed) and renders them into the
# container as plain AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (see commit
# 37ed671a, "Change AWS keys to use Woodpecker Secrets"). So ai.env must be
# written with the AI_-prefixed key names, NOT the plain ones — writing
# plain AWS_ACCESS_KEY_ID here would leave ${AI_AWS_ACCESS_KEY_ID}
# unresolved at compose-render time (empty), silently breaking Bedrock auth.
# All other migrated vars in ai.yaml use plain (unprefixed) names, so only
# these two lines need the AI_ prefix.
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "FILE=/volume1/docker/compose-files/ai/ai.env
TMP=\$FILE.tmp.\$\$ TMP=\$FILE.tmp.\$\$
grep -vE '^(AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|LITELLM_MASTER_KEY|LITELLM_SALT_KEY|POSTGRES_PASSWORD|DATABASE_URL|WEBUI_SECRET_KEY|OPEN_WEBUI_DATABASE_URL|OAUTH_CLIENT_SECRET)=' \$FILE > \$TMP 2>/dev/null || touch \$TMP grep -vE '^(AI_AWS_ACCESS_KEY_ID|AI_AWS_SECRET_ACCESS_KEY|LITELLM_MASTER_KEY|LITELLM_SALT_KEY|POSTGRES_PASSWORD|DATABASE_URL|WEBUI_SECRET_KEY|OPEN_WEBUI_DATABASE_URL|OAUTH_CLIENT_SECRET)=' \$FILE > \$TMP 2>/dev/null || touch \$TMP
{ cat \$TMP { cat \$TMP
printf 'AWS_ACCESS_KEY_ID=%s\n' '${AI_AWS_ACCESS_KEY_ID}' printf 'AI_AWS_ACCESS_KEY_ID=%s\n' '$${AI_AWS_ACCESS_KEY_ID}'
printf 'AWS_SECRET_ACCESS_KEY=%s\n' '${AI_AWS_SECRET_ACCESS_KEY}' printf 'AI_AWS_SECRET_ACCESS_KEY=%s\n' '$${AI_AWS_SECRET_ACCESS_KEY}'
printf 'LITELLM_MASTER_KEY=%s\n' '${AI_LITELLM_MASTER_KEY}' printf 'LITELLM_MASTER_KEY=%s\n' '$${AI_LITELLM_MASTER_KEY}'
printf 'LITELLM_SALT_KEY=%s\n' '${AI_LITELLM_SALT_KEY}' printf 'LITELLM_SALT_KEY=%s\n' '$${AI_LITELLM_SALT_KEY}'
printf 'POSTGRES_PASSWORD=%s\n' '${AI_LITELLM_DB_PASSWORD}' printf 'POSTGRES_PASSWORD=%s\n' '$${AI_LITELLM_DB_PASSWORD}'
printf 'DATABASE_URL=postgresql://LiteLLM:%s@postgresql:5432/litellm\n' '${AI_LITELLM_DB_PASSWORD}' printf 'DATABASE_URL=postgresql://LiteLLM:%s@postgresql:5432/litellm\n' '$${AI_LITELLM_DB_PASSWORD}'
printf 'WEBUI_SECRET_KEY=%s\n' '${AI_WEBUI_SECRET_KEY}' printf 'WEBUI_SECRET_KEY=%s\n' '$${AI_WEBUI_SECRET_KEY}'
printf 'OPEN_WEBUI_DATABASE_URL=%s\n' '${AI_OPEN_WEBUI_DATABASE_URL}' printf 'OPEN_WEBUI_DATABASE_URL=%s\n' '$${AI_OPEN_WEBUI_DATABASE_URL}'
printf 'OAUTH_CLIENT_SECRET=%s\n' '${AI_OAUTH_CLIENT_SECRET}' printf 'OAUTH_CLIENT_SECRET=%s\n' '$${AI_OAUTH_CLIENT_SECRET}'
} > \$FILE } > \$FILE
rm -f \$TMP rm -f \$TMP
echo ' [OK] ai/ai.env secrets updated'";; echo ' [OK] ai/ai.env secrets updated'";;
entertainment) entertainment)
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} "source /tmp/cs.sh ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'entertainment_discord_token' '${ENTERTAINMENT_DISCORD_TOKEN}' create_or_update_secret 'entertainment_discord_token' '$${ENTERTAINMENT_DISCORD_TOKEN}'
create_or_update_secret 'entertainment_discord_client_secret' '${ENTERTAINMENT_DISCORD_CLIENT_SECRET}' create_or_update_secret 'entertainment_discord_client_secret' '$${ENTERTAINMENT_DISCORD_CLIENT_SECRET}'
create_or_update_secret 'entertainment_secret_key_base' '${ENTERTAINMENT_SECRET_KEY_BASE}' create_or_update_secret 'entertainment_secret_key_base' '$${ENTERTAINMENT_SECRET_KEY_BASE}'
create_or_update_secret 'entertainment_basic_auth_password' '${ENTERTAINMENT_BASIC_AUTH_PASSWORD}' create_or_update_secret 'entertainment_basic_auth_password' '$${ENTERTAINMENT_BASIC_AUTH_PASSWORD}'
create_or_update_secret 'entertainment_sparky_db_password' '${ENTERTAINMENT_SPARKY_DB_PASSWORD}' create_or_update_secret 'entertainment_sparky_db_password' '$${ENTERTAINMENT_SPARKY_DB_PASSWORD}'
create_or_update_secret 'entertainment_sparky_app_db_password' '${ENTERTAINMENT_SPARKY_APP_DB_PASSWORD}' create_or_update_secret 'entertainment_sparky_app_db_password' '$${ENTERTAINMENT_SPARKY_APP_DB_PASSWORD}'
create_or_update_secret 'entertainment_sparky_encryption_key' '${ENTERTAINMENT_SPARKY_ENCRYPTION_KEY}' create_or_update_secret 'entertainment_sparky_encryption_key' '$${ENTERTAINMENT_SPARKY_ENCRYPTION_KEY}'
create_or_update_secret 'entertainment_better_auth_secret' '${ENTERTAINMENT_BETTER_AUTH_SECRET}'";; create_or_update_secret 'entertainment_better_auth_secret' '$${ENTERTAINMENT_BETTER_AUTH_SECRET}'";;
*) *)
echo " No secrets case for $STACK";; echo " No secrets case for $STACK";;
esac esac
@@ -290,11 +310,11 @@ steps:
- echo "$SSH_KEY" | base64 -d > ~/.ssh/id_rsa - echo "$SSH_KEY" | base64 -d > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa
- | - |
if [ -z "${SWARM_MANAGER_IP}" ]; then if [ -z "$${SWARM_MANAGER_IP}" ]; then
echo "ERROR: SWARM_MANAGER_IP secret is empty. Check Woodpecker repo secrets." echo "ERROR: SWARM_MANAGER_IP secret is empty. Check Woodpecker repo secrets."
exit 1 exit 1
fi fi
- ssh-keyscan -H ${SWARM_MANAGER_IP} >> ~/.ssh/known_hosts 2>/dev/null || true - ssh-keyscan -H $${SWARM_MANAGER_IP} >> ~/.ssh/known_hosts 2>/dev/null || true
- | - |
CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n') CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n')
FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true) FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true)
@@ -304,17 +324,17 @@ steps:
# Always sync deploy/ scripts first # Always sync deploy/ scripts first
rsync -av -e "ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa" \ rsync -av -e "ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa" \
deploy/ root@${SWARM_MANAGER_IP}:/volume1/docker/compose-files/deploy/ deploy/ root@$${SWARM_MANAGER_IP}:/volume1/docker/compose-files/deploy/
for STACK in $ALL_STACKS; do for STACK in $ALL_STACKS; do
echo "--- Deploying: $STACK ---" echo "--- Deploying: $STACK ---"
# Sync files to host first (always, even for bootstrap stacks) # Sync files to host first (always, even for bootstrap stacks)
if [ -d "$STACK" ]; then if [ -d "$STACK" ]; then
rsync -av -e "ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa" \ rsync -av -e "ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa" \
"$STACK/" root@${SWARM_MANAGER_IP}:/volume1/docker/compose-files/$STACK/ "$STACK/" root@$${SWARM_MANAGER_IP}:/volume1/docker/compose-files/$STACK/
elif [ -f "$STACK.yaml" ]; then elif [ -f "$STACK.yaml" ]; then
rsync -av -e "ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa" \ rsync -av -e "ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa" \
"$STACK.yaml" root@${SWARM_MANAGER_IP}:/volume1/docker/compose-files/ "$STACK.yaml" root@$${SWARM_MANAGER_IP}:/volume1/docker/compose-files/
fi fi
# Bootstrap-tier guard: file synced to host, deploy is MANUAL # Bootstrap-tier guard: file synced to host, deploy is MANUAL
@@ -326,7 +346,7 @@ steps:
esac esac
# Tier 2: auto-deploy # Tier 2: auto-deploy
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} \ ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} \
"bash /volume1/docker/compose-files/deploy/stack-deploy.sh $STACK" \ "bash /volume1/docker/compose-files/deploy/stack-deploy.sh $STACK" \
&& echo " OK $STACK" || { echo " FAIL $STACK"; exit 1; } && echo " OK $STACK" || { echo " FAIL $STACK"; exit 1; }
done done
@@ -344,23 +364,47 @@ steps:
- echo "$SSH_KEY" | base64 -d > ~/.ssh/id_rsa - echo "$SSH_KEY" | base64 -d > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa
- | - |
if [ -z "${SWARM_MANAGER_IP}" ]; then if [ -z "$${SWARM_MANAGER_IP}" ]; then
echo "ERROR: SWARM_MANAGER_IP secret is empty. Check Woodpecker repo secrets." echo "ERROR: SWARM_MANAGER_IP secret is empty. Check Woodpecker repo secrets."
exit 1 exit 1
fi fi
- ssh-keyscan -H ${SWARM_MANAGER_IP} >> ~/.ssh/known_hosts 2>/dev/null || true - ssh-keyscan -H $${SWARM_MANAGER_IP} >> ~/.ssh/known_hosts 2>/dev/null || true
- | - |
CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n') CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n')
FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true) FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true)
FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | sort -u || true) FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | sort -u || true)
ALL_STACKS=$(printf '%s\n%s' "$FLAT_STACKS" "$FOLDER_STACKS" | grep -v '^$' | sort -u) ALL_STACKS=$(printf '%s\n%s' "$FLAT_STACKS" "$FOLDER_STACKS" | grep -v '^$' | sort -u)
[ -z "$ALL_STACKS" ] && exit 0 [ -z "$ALL_STACKS" ] && exit 0
sleep 5
# NOTE: `docker stack deploy` briefly tears down and recreates tasks in
# Swarm's internal bookkeeping, so `docker stack ps` can transiently
# return nothing right after deploy even when the service is healthy.
# A single `sleep 5` + one-shot check produced false-alarm-looking
# "nothing found in stack" output on ordinary deploys (e.g. vaultwarden,
# 2026-08-25). Retry with backoff instead of a single fixed sleep, and
# only warn (don't fail the pipeline) if tasks never show up.
ATTEMPTS=6
DELAY=5
for STACK in $ALL_STACKS; do for STACK in $ALL_STACKS; do
echo "--- $STACK ---" echo "--- $STACK ---"
ssh -o StrictHostKeyChecking=no root@${SWARM_MANAGER_IP} \ i=1
while [ "$i" -le "$ATTEMPTS" ]; do
OUTPUT=$(ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} \
"docker stack ps $STACK --filter desired-state=running \ "docker stack ps $STACK --filter desired-state=running \
--format ' {{.Name}} {{.CurrentState}}'" --format ' {{.Name}} {{.CurrentState}}'" 2>/dev/null)
if [ -n "$OUTPUT" ]; then
echo "$OUTPUT"
break
fi
if [ "$i" -eq "$ATTEMPTS" ]; then
echo " WARNING: no running tasks found for $STACK after $((ATTEMPTS * DELAY))s."
echo " This may be transient Swarm settle time, or a real problem — check manually:"
echo " ssh root@$${SWARM_MANAGER_IP} 'docker stack ps $STACK --no-trunc'"
else
sleep "$DELAY"
fi
i=$((i + 1))
done
done done
notify-success: notify-success:
+5
View File
@@ -14,3 +14,8 @@
# 2. Select repository: homelab/compose-files # 2. Select repository: homelab/compose-files
# 3. Settings → Secrets # 3. Settings → Secrets
# 4. Add SSH_KEY and TEAMS_WEBHOOK # 4. Add SSH_KEY and TEAMS_WEBHOOK
# ai stack secrets (added 2026-08-25, see PR #4):
# ai_aws_access_key_id, ai_aws_secret_access_key, ai_litellm_master_key,
# ai_litellm_salt_key, ai_litellm_db_password, ai_webui_secret_key,
# ai_open_webui_database_url, ai_oauth_client_secret
+5
View File
@@ -1,3 +1,8 @@
# NOTE: litellm's AWS/DB/master-key secrets, open-webui's secret key/DB URL,
# and the OAuth client secret are provisioned into ai/ai.env at deploy time
# from Woodpecker secrets (see PRs #4, #6, #7) -- not committed here.
# MCPO_API_KEY (used by mcpo and mcpo-critical) is NOT yet migrated; it
# remains manually managed in ai/ai.env by design (see PR #4 discussion).
services: services:
open-webui: open-webui:
image: ghcr.io/open-webui/open-webui:0.11.1 image: ghcr.io/open-webui/open-webui:0.11.1
+46 -2
View File
@@ -22,14 +22,58 @@ def merge_envs(base_path, override_path):
merged = {**base, **override} merged = {**base, **override}
return list(merged.items()) 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('$', '$$')
mode = sys.argv[1] mode = sys.argv[1]
if mode == 'export': if mode == 'export':
for k, v in parse_env(sys.argv[2]): for k, v in parse_env(sys.argv[2]):
print('export {}={}'.format(k, repr(v))) print('export {}={}'.format(k, repr(escape_dollar(v))))
elif mode == 'export_merged': elif mode == 'export_merged':
# export_merged <global.env> <stack.env> # export_merged <global.env> <stack.env>
for k, v in merge_envs(sys.argv[2], sys.argv[3]): for k, v in merge_envs(sys.argv[2], sys.argv[3]):
print('export {}={}'.format(k, repr(v))) print('export {}={}'.format(k, repr(escape_dollar(v))))
elif mode == 'vars': elif mode == 'vars':
print(' '.join('$' + k for k, v in parse_env(sys.argv[2]))) print(' '.join('$' + k for k, v in parse_env(sys.argv[2])))
elif mode == 'vars_merged': elif mode == 'vars_merged':