Author SHA1 Message Date
Bot 5ab1ae1e8b chore(traefik): re-trigger provision-secrets — traefik.env was found missing on disk post-migration
ci/woodpecker/push/deploy Pipeline was successful
traefik/traefik.env did not exist on the host after PR #21 merged. Root
cause: provisioning only renders on a push touching traefik/, and traefik
is bootstrap-tier (stack-deploy.sh runs manually, never via the pipeline).
A manual stack-deploy.sh traefik run apparently happened while the env file
was absent, causing Swarm's own interpolation to render
KEEPALIVED_PASSWORD/KEEPALIVED_VIRTUAL_IPS as empty strings on
keepalived-master. This comment-only change re-triggers provision-secrets;
a manual stack-deploy.sh traefik run is still required afterward to apply
the freshly-rendered env to the running services.
2026-09-11 23:17:13 -07:00
AVB afc96a6d37 Merge pull request 'fix(traefik): migrate KEEPALIVED_PASSWORD to Pattern C (manifest-driven), stop committing traefik.env' (#21) from migrate-traefik-keepalived-secret into main
ci/woodpecker/push/deploy Pipeline was successful
Reviewed-on: #21
https://ai.bryanmail.net/c/7440e9d5-7e39-40f9-a35a-33016b6ae8d1
2026-09-11 22:52:35 -07:00
Bot cd42e35265 docs(secrets): mark traefik migration complete, add committed-env-file warning 2026-09-11 22:42:06 -07:00
Bot 2a3f2bdd1e docs(traefik): update secrets reference for manifest-driven Pattern C migration 2026-09-11 22:42:04 -07:00
Bot d9f151a1c3 feat(traefik): migrate keepalived password to manifest-driven Pattern C provisioning
Root cause of the 2026-09-12 VIP instability incident: traefik/traefik.env
was committed to git with a literal "***REDACTED***" placeholder as
KEEPALIVED_PASSWORD. Every git-guard resync/checkout restored that broken
value onto disk, diverging from keepalived-backup's stale-but-correct
in-memory value and causing a continuous VRRP auth failure + VIP flapping.

- Adds TRAEFIK_KEEPALIVED_PASSWORD (from_secret: traefik_keepalived_password)
  to provision-secrets environment block
- Replaces the traefik legacy case-entry (previously grouped with
  maintenance|media|unifi|... as "no Docker secrets, host .env only") with
  a call to deploy/provision-stack.py traefik, matching the ai) stack's
  manifest-driven pattern
- traefik/traefik.env is now rendered fresh from traefik/traefik.env.template
  on every provisioning run and never committed to git again

Diffed $${ occurrence count against previous version before committing,
per this file's own 2026-08-26 incident note.
2026-09-11 22:40:36 -07:00
Bot 201d8418ee feat(traefik): register traefik in manifest-driven secrets provisioning 2026-09-11 22:38:39 -07:00
Bot 5edebd977d fix(traefik): remove tracked traefik.env — never commit secret-bearing env files
The committed version contained a literal "***REDACTED***" placeholder as
KEEPALIVED_PASSWORD, which was restored onto disk every time the real file
was deleted or the local checkout re-synced from git. This caused VRRP auth
to mismatch between keepalived-master and keepalived-backup (master picked
up the broken committed value, backup retained a stale-but-correct value),
producing a continuous "(VI_1) received an invalid passwd!" flood and VIP
instability. traefik.env is now rendered fresh from traefik.env.template by
deploy/provision-stack.py on every provisioning run and never committed.
2026-09-11 22:38:39 -07:00
Bot ffec3ea5b7 feat(traefik): add authoritative env template for manifest-driven provisioning 2026-09-11 22:38:38 -07:00
Bot 5dda5e0031 fix(frigate): publish go2rtc WebRTC port 8555 (tcp+udp) on frigate-nvr
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
Camera cards using advanced-camera-card were stuck retrying WebRTC ICE
negotiation every few seconds (visible as dashboards "refreshing").
frigate-nvr had no published ports at all, so go2rtc's WebRTC listener
on 8555 was unreachable from browsers despite valid ICE candidates
already configured (VIP + CIDR + stun) in frigate/config.yml.

Publishing via ingress mode keeps this working regardless of which
swarm node Frigate lands on (no placement constraint on this service).

No Traefik/auth changes - HA<->Frigate signaling was already working
correctly over traefik_backend; only the browser<->go2rtc media path
was broken.
2026-09-09 22:09:30 -07:00
AVB d5db4c1a4e fix(git-guard): use 'exec bash "$0"' for self-re-invocation (exec bit not required) (#20)
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-09-09 19:36:31 -07:00
Bot 333bb82c16 fix(git-guard): use 'exec bash "$0"' instead of 'exec "$0"' for self-re-invocation
Found via live testing on docker-2 after merging #19: git-guard.sh is
tracked in git at mode 100644 (not executable). stack-deploy.sh correctly
invokes it as `bash deploy/git-guard.sh`, sidestepping the exec bit for the
first call -- but the script's own internal `exec "$0" "$@"` calls (used to
cleanly re-run after a successful push or stash-pop) rely on the kernel
executing the file directly, which requires +x. Since every fresh checkout
or fast-forward preserves the tracked 644 mode, this failed with
"Permission denied" (exit 126) the moment either self-re-invocation path
was actually exercised.

Confirmed via a live dry run: simulated the exact #18 incident shape (dirty
git-guard.sh on a HEAD 3 commits behind origin/main) on docker-2. The new
stash-first logic from #19 worked perfectly end-to-end -- detected
dirty+stale, stashed safely, fast-forwarded via resync_with_origin(), and
popped the stash cleanly -- but then hit this pre-existing bug on the
final `exec "$0" "$@"` re-invocation. This bug pre-dates #19 (the old
dirty-commit-then-push-success path had the identical pattern); #19 just
added a second trigger point that happened to surface it during testing.

Fix: `exec bash "$0" "$@"` explicitly invokes through the interpreter
instead of relying on the file's own execute bit -- correct regardless of
what git tracks the file's mode as.
2026-09-09 14:01:26 -07:00
Bot d72a8ebd04 Merge pull request 'fix(git-guard): stash-before-commit when dirty tree is also stale vs origin' (#19) from fix-git-guard-stash-before-commit into main
ci/woodpecker/push/deploy Pipeline was successful
2026-09-09 13:56:05 -07:00
Bot e357907ee6 fix(git-guard): add unmerged-path guard + concrete remediation syntax for every failure branch
Adds the unmerged-conflict guard flagged in PR review: before the dirty-tree
flow can run, check `git ls-files -u` and refuse to auto-commit if unresolved
merge conflict markers are present (e.g. left behind by a prior run's failed
`git stash pop`). Without this, a re-run's `git add -A` would silently stage
literal <<<<<<< / ======= / >>>>>>> markers into a real commit and push them
to origin/main.

Also expands every failure-path message (stash-push failure, stash-pop
conflict vs. untracked-file-collision, resync failure, true divergence,
manual-abort cases) to include concrete, copy-pasteable remediation command
sequences with multiple options (resolve-in-place vs. abandon-and-reapply vs.
drop-if-unneeded), rather than a single generic hint. The true-divergence
message now also tells the operator to check `git stash list` first, so a
stash created by the new dirty+stale path isn't confused with a second
manually-created one.

No change to the common dirty-but-current or clean-but-stale behavior.
2026-09-09 13:47:14 -07:00
Bot bd69cc85d5 fix(git-guard): stash-before-commit when dirty tree is also stale vs origin
Previously the dirty-tree branch committed unconditionally, then only
discovered staleness/divergence when the push was rejected -- leaving a
doomed auto-commit sitting on a stale base. Root-caused from a real
incident: a stray on-disk edit to ai/ai.yaml sat on a checkout that was
already 4 commits behind, git-guard auto-committed on top of it, then
the push was rejected (ahead 1, behind 4), aborting the deploy.

Fix: when DIRTY=1 and LOCAL != REMOTE, stash the dirty changes first,
resync main with origin using the exact same behind/ahead/diverged
rules as the clean-tree path (now shared via resync_with_origin()),
then reapply the stash and re-run. Never commits on top of a stale
base again. On any failure the stash is preserved and printed for
manual recovery -- changes are never silently lost.
2026-09-09 10:42:21 -07:00
AVB 000792f702 ai: persist FlowAgent MSAL token cache via mcpo bind mount (#18)
ci/woodpecker/push/deploy Pipeline was successful
https://ai.bryanmail.net/s/0255f5cf-cb2a-4f8d-a782-caacc9dfd62f
2026-09-09 08:45:02 -07:00
Bot 9316a32ba5 ai: persist FlowAgent MSAL token cache via mcpo bind mount
Adds the missing bind mount for FlowAgent's delegated-user MSAL disk
token cache:

    /volume1/docker/mcpo/flowagent-auth:/app/flowagent-auth

Confirmed via `docker service inspect ai_mcpo --format '{{json .Spec.TaskTemplate.ContainerSpec.Mounts}}'`-equivalent review of the current
compose that mcpo's mounts were only: ssh_keys, config.json, mcpo_data,
uv-cache -- flowagent-auth (set as HOME for the flowagent subprocess via
config.json.template's env.HOME=/app/flowagent-auth) was NOT persisted,
so any successful delegated-user MSAL sign-in (list_connections /
create_connection / resolve_refs / pick_or_create_connection /
resolve_params -- the tools that use MsalTokenProvider, see companion
homelab/mcp-config PR #9) would be silently lost on every mcpo restart,
forcing a fresh interactive sign-in every time.

Host dir already exists (pre-created on shared CephFS, identical across
all three docker LXCs):
    mkdir -p /volume1/docker/mcpo/flowagent-auth

This does NOT change the flowagent image tag/pin -- that stays
git.bryanmail.net/homelab/flowagent-mcpo:c5b22618404a pending the
companion mcp-config PR merging and its Woodpecker build producing a new
commit-sha tag to bump to.

No other changes to ai.yaml in this commit -- diff is mount-list addition
only.
2026-09-09 00:16:35 -07:00
AVB 5a5c8e56a9 Merge pull request 'Fix phantom verify failure: secrets/ is not a stack + errexit-proof verify' (#17) from fix-secrets-folder-stack-detection into main
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/cron/renovate Pipeline failed
Reviewed-on: #17
https://ai.bryanmail.net/c/9f4c84c7-7aa1-4b24-a27e-98be6a5e737e
2026-09-07 22:28:36 -07:00
Bot eee6e543b1 Fix phantom verify failure: exclude secrets/ from stack detection + errexit-proof verify's stack-ps
Root cause of PR #16's pipeline failure (#398-#401): secrets/ is a
tooling folder (secrets-map.yaml, *.secrets.example), not a stack, but
folder-detection (cut -d/ -f1, excluding only deploy/) computed
ALL_STACKS="ai secrets" the first time a commit touched secrets/. The
deploy step survived only because 'secrets' coincidentally sits in the
bootstrap-tier skip list; verify had no guard — `docker stack ps
secrets` exited non-zero and, under errexit, the OUTPUT=$(...)
assignment killed the step before the designed WARNING path could run.
The ai deploy itself was fully successful (complete 21-key render, all
6 services healthy) — the failure was pure phantom. Hazard first
flagged July 2026 in PR #3 (closed unmerged).

Changes:
- grep -v '^deploy$'  ->  grep -vE '^(deploy|secrets)$' in ALL five
  folder-detection sites (validate, provision-secrets x2, deploy,
  verify)
- verify: `|| true` inside the stack-ps command substitution so a
  genuinely missing stack warns instead of aborting (comment added)
- deploy step: also rsync secrets/ tooling to the host mirror
  unconditionally, same treatment as deploy/
- header: 2026-09-08 incident note

Everything else byte-identical to main. Dollar-escape audit done.
2026-09-07 22:26:43 -07:00
AVB be587de5be Merge pull request 'Provisioning v2 (take 2): data-only manifest + full-file env template + generic provisioner — replaces #15' (#16) from secrets-provisioning-v3 into main
ci/woodpecker/push/deploy Pipeline failed
Reviewed-on: #16
https://ai.bryanmail.net/c/9f4c84c7-7aa1-4b24-a27e-98be6a5e737e
2026-09-07 22:06:03 -07:00
Bot ef7e3d015e provision-secrets: ai) case -> provision-stack.py + normalized env block (branch rebuilt on current main)
Same two scoped changes as PR #15's rebase, now applied on a branch cut
from TODAY's main so the merge base is current (PR #15 became
unmergeable — its 09-03 merge base predated main's heavy hand-edits):

1. ai) case -> `python3 deploy/provision-stack.py ai` (one line). Retires
   main's heredoc with its live drift: AI_LITELLM_POSTGRES_PASSWORD
   printed twice (one copy from retired AI_LITELLM_DB_PASSWORD), stray
   legacy AI_WEBUI_SECRET_KEY, and the WEB_UI/WEBUI shell-var mismatch
   that renders an EMPTY OAuth client secret into ai.env.
2. env block ai section: normalized to exact template placeholder names,
   added AI_MCPO_API_KEY (NEW Woodpecker secret required pre-merge),
   pruned now-template-literal vars.

apk gains python3 py3-yaml. Everything else byte-matches current main.
Dollar-escape audit done per header lesson.
2026-09-07 21:47:04 -07:00
Bot 89a1e030c0 Add deploy/provision-stack.py — generic manifest-driven provisioner
Carried over from secrets-provisioning-v2 (PR #15), content identical.
Whole-file template render (hard-fail naming missing vars), env + Docker
secrets shipped via ssh stdin only, sha256-checksum skip-if-unchanged,
never prints a value. Stacks absent from the manifest exit 0 so legacy
case-entries keep handling them.
2026-09-07 21:43:57 -07:00
Bot e82b754386 Add ai/ai.env.template — full-file authoritative env template (AI_<SERVICE>_* naming)
Carried over from secrets-provisioning-v2 (PR #15) post-rebase. Key names
match exactly what current ai/ai.yaml references; MCPO_API_KEY stays
unprefixed (ai.yaml consumes it unprefixed for both mcpo services).
Placeholder names match the CI env vars declared in deploy.yml's
provision-secrets block, including the normalized
AI_OPEN_WEBUI_OAUTH_CLIENT_SECRET (fixing main's WEB_UI/WEBUI mismatch
that currently renders an EMPTY OAuth client secret into ai.env).
NEW Woodpecker secret required before merge: ai_mcpo_api_key.
2026-09-07 21:43:05 -07:00
Bot 85734f4601 Add secrets/secrets-map.yaml — data-only provisioning manifest (ai stack first)
Carried over from secrets-provisioning-v2 (PR #15), which became
unmergeable after heavy parallel hand-edits to main moved the merge base
too far. Content identical to v2.
2026-09-07 21:43:04 -07:00
AVB 264f4e7379 Change AWS_REGION_NAME variable
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-09-06 20:33:56 -07:00
AVB 9bbbd5fae3 Add AWS Region Name variable
ci/woodpecker/push/deploy Pipeline was successful
2026-09-06 20:33:08 -07:00
AVB e33fd8695f Update ai/ai.yaml
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-09-06 10:59:05 -07:00
AVB 1828c89194 Update .woodpecker/deploy.yml
ci/woodpecker/push/deploy Pipeline was successful
2026-09-06 10:53:55 -07:00
AVB 9ec5353d7e Fixed LITELLM Database URL
ci/woodpecker/push/deploy Pipeline was successful
2026-09-06 10:44:53 -07:00
AVB dfa0d34a83 Update ai/ai.yaml
ci/woodpecker/push/deploy Pipeline was successful
2026-09-06 10:29:27 -07:00
AVB 66638dd743 Update ai/ai.yaml
ci/woodpecker/push/deploy Pipeline was successful
2026-09-06 01:29:02 -07:00
Bot 355ccada8c Update ai/ai.yaml
ci/woodpecker/push/deploy Pipeline failed
ci/woodpecker/cron/renovate Pipeline was successful
2026-09-03 00:15:38 -07:00
Bot 096ebd6de5 Update .woodpecker/deploy.yml
ci/woodpecker/push/deploy Pipeline was successful
2026-09-02 23:28:25 -07:00
Bot 980c54f84e Update .woodpecker/deploy.yml 2026-09-02 23:27:19 -07:00
Bot 324a06ed2d Update .woodpecker/deploy.yml 2026-09-02 23:26:30 -07:00
Bot cee47822ec Update .woodpecker/deploy.yml 2026-09-02 23:25:39 -07:00
Bot 68365746a6 Update .woodpecker/deploy.yml 2026-09-02 23:24:37 -07:00
Bot eecfcf9367 Update .woodpecker/deploy.yml 2026-09-02 23:23:57 -07:00
Bot d01c763354 Update .woodpecker/deploy.yml
Redeploy after adding secret:
secret "ai_open_web_ui_openid_provider_url" not found
2026-09-02 23:17:48 -07:00
Bot 0baee47b67 Fixed duplicate env: "AI_OPEN_WEB_UI_ENABLE_OAUTH_SIG"
yaml: construct errors: line 190: mapping key "AI_OPEN_WEB_UI_ENABLE_OAUTH_SIGNUP" already defined at line 186
2026-09-02 23:02:51 -07:00
Bot 0435def8a0 Update .woodpecker/deploy.yml
Added Open WebUI and LiteLLM secrets
2026-09-02 23:01:44 -07:00
Bot ded255decc Upgrade to open-webui:0.11.3
ci/woodpecker/push/deploy Pipeline was successful
open-webui:0.11.1 > open-webui:0.11.3
2026-09-02 22:26:15 -07:00
Bot ac091999b4 Merge pull request 'FlowAgent MCP cutover: point ai.yaml's mcpo at flowagent-mcpo image + wire Azure secrets (matched pair)' (#14) from flowagent-ai-cutover into main
ci/woodpecker/push/deploy Pipeline failed
Reviewed-on: #14
https://ai.bryanmail.net/c/9f4c84c7-7aa1-4b24-a27e-98be6a5e737e
2026-09-02 21:43:18 -07:00
11 changed files with 783 additions and 172 deletions
+96 -62
View File
@@ -48,6 +48,38 @@ when:
# already unconditionally rsynced at the top of the deploy step regardless
# of which stacks changed, so it's safe to exclude it from the stack list
# everywhere folders are detected below.
#
# 2026-09-03/07 INCIDENT + REDESIGN: hand-maintained grep -v + printf
# line-surgery cases repeatedly drifted (missing '=', duplicated keys,
# mismatched env var names rendering EMPTY secrets) and broke live
# services. Root cause: no authoritative key list and shell heredocs
# hostile to hand-editing. Stacks migrate one at a time to a data-driven
# model: secrets/secrets-map.yaml (data only) + per-stack .env.template
# (authoritative FULL file) + deploy/provision-stack.py (whole-file
# render, hard failure naming any missing value). Migrated stacks call
# the script; unmigrated stacks keep legacy case entries until their own
# PR. See the "Secrets & Deployment Architecture — Global Direction" note.
#
# 2026-09-08 FIX: secrets/ is a tooling/docs folder (secrets-map.yaml +
# *.secrets.example), not a stack — but folder-detection treated it as one
# the first time a commit touched it (PR #16). deploy survived only because
# 'secrets' sits in the bootstrap-tier skip list; verify had no guard and
# died on `docker stack ps secrets` failing under errexit (assignment from
# a failing command substitution aborts the step). Fixed by excluding
# secrets/ alongside deploy/ in ALL folder-detection sites, and by
# tolerating a failing stack-ps in verify (|| true) so a genuinely missing
# stack produces the designed WARNING instead of killing the step. This
# hazard was first flagged in July (PR #3, closed unmerged).
#
# 2026-09-12 MIGRATION: traefik moved from Pattern B (secrets hand-typed
# into the host-only traefik.env, no CI involvement at all) to the same
# manifest-driven provisioning as ai) below. Root cause of the migration:
# traefik.env had been accidentally committed to git with a literal
# "***REDACTED***" placeholder as KEEPALIVED_PASSWORD; every git-guard
# resync/checkout silently restored that broken value, which diverged from
# keepalived-backup's stale-but-correct in-memory value and produced a
# continuous VRRP auth failure ("received an invalid passwd!") and VIP
# instability. The manifest-driven path never commits the rendered file.
# ─────────────────────────────────────────────────────────────────────────────
steps:
@@ -65,9 +97,9 @@ steps:
FLAT=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.ya?ml$' || true)
# Folder: any file under a subfolder (e.g. immich/immich.yml).
# Exclude dotfolders (.woodpecker, .git, .gitea, etc.) and deploy/
# (shared tooling, not a stack — see note above).
FOLDERS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | grep -v '^deploy$' | sort -u || true)
# Exclude dotfolders (.woodpecker, .git, .gitea, etc.) and the
# non-stack tooling folders deploy/ and secrets/ (see notes above).
FOLDERS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | grep -vE '^(deploy|secrets)$' | sort -u || true)
[ -z "$FLAT" ] && [ -z "$FOLDERS" ] && echo "No stacks changed" && exit 0
@@ -163,6 +195,11 @@ steps:
from_secret: entertainment_sparky_encryption_key
ENTERTAINMENT_BETTER_AUTH_SECRET:
from_secret: entertainment_better_auth_secret
# ── ai stack (manifest-driven — secrets/secrets-map.yaml +
# ai/ai.env.template + deploy/provision-stack.py). Env var names
# below match the template placeholders EXACTLY; this block is the
# only per-secret touchpoint left in this file for migrated stacks
# (Woodpecker v3 requires explicit from_secret declarations). ──
AI_AWS_ACCESS_KEY_ID:
from_secret: ai_aws_access_key_id
AI_AWS_SECRET_ACCESS_KEY:
@@ -171,22 +208,31 @@ steps:
from_secret: ai_litellm_master_key
AI_LITELLM_SALT_KEY:
from_secret: ai_litellm_salt_key
AI_LITELLM_DB_PASSWORD:
from_secret: ai_litellm_db_password
AI_WEBUI_SECRET_KEY:
AI_LITELLM_DATABASE_URL:
from_secret: ai_litellm_database_url
AI_LITELLM_POSTGRES_PASSWORD:
from_secret: ai_litellm_postgres_password
AI_OPEN_WEBUI_SECRET_KEY:
from_secret: ai_webui_secret_key
AI_OPEN_WEBUI_DATABASE_URL:
from_secret: ai_open_webui_database_url
AI_OAUTH_CLIENT_SECRET:
AI_OPEN_WEBUI_OAUTH_CLIENT_SECRET:
from_secret: ai_oauth_client_secret
AI_MCPO_API_KEY:
from_secret: ai_mcpo_api_key
FLOWAGENT_AZURE_CLIENT_ID:
from_secret: flowagent_azure_client_id
FLOWAGENT_AZURE_TENANT_ID:
from_secret: flowagent_azure_tenant_id
FLOWAGENT_AZURE_CLIENT_SECRET:
from_secret: flowagent_azure_client_secret
# ── traefik stack (manifest-driven — secrets/secrets-map.yaml +
# traefik/traefik.env.template + deploy/provision-stack.py).
# Migrated 2026-09-12; see header note above for root cause. ──
TRAEFIK_KEEPALIVED_PASSWORD:
from_secret: traefik_keepalived_password
commands:
- apk add --no-cache openssh-client
- apk add --no-cache openssh-client python3 py3-yaml
- mkdir -p ~/.ssh
- echo "$SSH_KEY" | base64 -d > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
@@ -199,21 +245,32 @@ steps:
- |
CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n')
FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true)
FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | grep -v '^deploy$' | sort -u || true)
FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | grep -vE '^(deploy|secrets)$' | sort -u || true)
ALL_STACKS=$(printf '%s\n%s' "$FLAT_STACKS" "$FOLDER_STACKS" | grep -v '^$' | sort -u)
[ -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
- |
CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n')
FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true)
FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | grep -v '^deploy$' | sort -u || true)
FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | grep -vE '^(deploy|secrets)$' | sort -u || true)
ALL_STACKS=$(printf '%s\n%s' "$FLAT_STACKS" "$FOLDER_STACKS" | grep -v '^$' | sort -u)
for STACK in $ALL_STACKS; do
echo "Provisioning: $STACK"
case "$STACK" in
maintenance|media|unifi|guacamole|security|auth|traefik|meshcentral|ddm)
maintenance|media|unifi|guacamole|security|auth|meshcentral|ddm)
echo " No Docker secrets for $STACK — secrets in host .env";;
traefik)
# MIGRATED (2026-09-12) to manifest-driven provisioning after
# discovering traefik.env was committed to git with a literal
# "***REDACTED***" placeholder as KEEPALIVED_PASSWORD — every
# git-guard resync/checkout restored the broken value, causing
# VRRP auth mismatch between keepalived-master/-backup and VIP
# instability. All logic lives in deploy/provision-stack.py;
# the authoritative key list lives in
# traefik/traefik.env.template; the mapping lives in
# secrets/secrets-map.yaml. This case is intentionally one line.
python3 deploy/provision-stack.py traefik;;
immich)
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'immich_db_password' '$${IMMICH_DB_PASSWORD}'
@@ -233,9 +290,9 @@ steps:
# dependency on a running Swarm's Docker secret store. Native Docker
# secrets (Pattern C) can't satisfy that: they only exist inside an
# already-running Swarm, which is exactly the circular dependency
# this stack can't have. Mirrors the ai) case's grep -v + printf
# rewrite-in-place approach, never sed (values may contain slash,
# dollar sign, ampersand).
# this stack can't have. Mirrors the retired ai) case's grep -v +
# printf rewrite-in-place approach, never sed (values may contain
# slash, dollar sign, ampersand).
#
# TEST PHASE: target is git.env.pipelinetest, NOT the real git.env.
# The real file is never opened for writing by this step. First run
@@ -246,6 +303,8 @@ steps:
# documented GITEA_MCP_ACCESS_TOKEN key. Cutover to the real file —
# and pointing git.yaml/stack-deploy at it — is a deliberate,
# separate follow-up after manually diffing this render.
# (Candidate for the secrets-map.yaml/provision-stack.py migration
# in its own PR; kept legacy for now.)
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "FILE=/volume1/docker/compose-files/git.env.pipelinetest
TMP=\$FILE.tmp.\$\$
[ -f \$FILE ] || cp /volume1/docker/compose-files/git.env \$FILE
@@ -293,51 +352,15 @@ steps:
create_or_update_secret 'vaultwarden_admin_token' '$${VAULTWARDEN_ADMIN_TOKEN}'
create_or_update_secret 'vaultwarden_database_url_v2' '$${VAULTWARDEN_DATABASE_URL}'";;
ai)
# 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
# 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
# may contain slash, dollar sign, ampersand). All other lines — including
# MCPO_API_KEY, OAUTH_CLIENT_ID, WEBUI_URL, and other non-secret config — are
# left completely untouched. MCPO_API_KEY migration is deferred to a
# follow-up; this step never reads or writes it.
#
# 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.
#
# FLOWAGENT NOTE (added alongside the ai.yaml mcpo image/secrets cutover):
# the 3 flowagent_azure_* values are provisioned as native Docker secrets
# below (Pattern C, matches every other _FILE-convention stack), NOT written
# into ai/ai.env — mcpo's flowagent entry reads them via
# /run/secrets/flowagent_azure_* (see mcp-config/flowagent/entrypoint.sh),
# not via env var, so they don't belong in this stack's Pattern B .env block.
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "FILE=/volume1/docker/compose-files/ai/ai.env
TMP=\$FILE.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
printf 'AI_AWS_ACCESS_KEY_ID=%s\n' '$${AI_AWS_ACCESS_KEY_ID}'
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_SALT_KEY=%s\n' '$${AI_LITELLM_SALT_KEY}'
printf 'POSTGRES_PASSWORD=%s\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 'OPEN_WEBUI_DATABASE_URL=%s\n' '$${AI_OPEN_WEBUI_DATABASE_URL}'
printf 'OAUTH_CLIENT_SECRET=%s\n' '$${AI_OAUTH_CLIENT_SECRET}'
} > \$FILE
rm -f \$TMP
echo ' [OK] ai/ai.env secrets updated'
source /tmp/cs.sh
create_or_update_secret 'flowagent_azure_client_id' '$${FLOWAGENT_AZURE_CLIENT_ID}'
create_or_update_secret 'flowagent_azure_tenant_id' '$${FLOWAGENT_AZURE_TENANT_ID}'
create_or_update_secret 'flowagent_azure_client_secret' '$${FLOWAGENT_AZURE_CLIENT_SECRET}'";;
# MIGRATED (2026-09-07) to manifest-driven provisioning after the
# line-surgery approach repeatedly drifted (dropped keys 2026-09-03;
# duplicate AI_LITELLM_POSTGRES_PASSWORD + stray legacy keys +
# WEB_UI/WEBUI env-name mismatch rendering an EMPTY OAuth client
# secret, found 2026-09-07). All logic lives in
# deploy/provision-stack.py; the authoritative key list lives in
# ai/ai.env.template; the mapping lives in secrets/secrets-map.yaml.
# This case is intentionally one line.
python3 deploy/provision-stack.py ai;;
entertainment)
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'entertainment_discord_token' '$${ENTERTAINMENT_DISCORD_TOKEN}'
@@ -374,7 +397,7 @@ steps:
- |
CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n')
FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true)
FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | grep -v '^deploy$' | sort -u || true)
FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | grep -vE '^(deploy|secrets)$' | sort -u || true)
ALL_STACKS=$(printf '%s\n%s' "$FLAT_STACKS" "$FOLDER_STACKS" | grep -v '^$' | sort -u)
[ -z "$ALL_STACKS" ] && echo "No stacks changed" && exit 0
@@ -382,6 +405,13 @@ steps:
rsync -av -e "ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa" \
deploy/ root@$${SWARM_MANAGER_IP}:/volume1/docker/compose-files/deploy/
# Sync secrets/ tooling (manifest + examples) alongside deploy/ —
# provision-stack.py reads secrets/secrets-map.yaml from the CI
# checkout, but the host mirror should stay complete for emergency
# manual provisioning runs.
rsync -av -e "ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa" \
secrets/ root@$${SWARM_MANAGER_IP}:/volume1/docker/compose-files/secrets/
for STACK in $ALL_STACKS; do
echo "--- Deploying: $STACK ---"
# Sync files to host first (always, even for bootstrap stacks)
@@ -428,7 +458,7 @@ steps:
- |
CHANGED_FILES=$(echo "${CI_PIPELINE_FILES}" | tr -d '[]"' | tr ',' '\n')
FLAT_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/]+\.yaml$' | sed 's/\.yaml$//' || true)
FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | grep -v '^deploy$' | sort -u || true)
FOLDER_STACKS=$(echo "$CHANGED_FILES" | grep -E '^[^/.][^/]*/' | cut -d/ -f1 | grep -vE '^(deploy|secrets)$' | sort -u || true)
ALL_STACKS=$(printf '%s\n%s' "$FLAT_STACKS" "$FOLDER_STACKS" | grep -v '^$' | sort -u)
[ -z "$ALL_STACKS" ] && exit 0
@@ -439,6 +469,10 @@ steps:
# "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.
# 2026-09-08: `|| true` inside the command substitution is REQUIRED —
# this step runs under errexit, and an assignment from a failing
# command substitution (e.g. `docker stack ps` on a stack that doesn't
# exist) kills the whole step before the WARNING path can run.
ATTEMPTS=6
DELAY=5
for STACK in $ALL_STACKS; do
@@ -447,7 +481,7 @@ steps:
while [ "$i" -le "$ATTEMPTS" ]; do
OUTPUT=$(ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} \
"docker stack ps $STACK --filter desired-state=running \
--format ' {{.Name}} {{.CurrentState}}'" 2>/dev/null)
--format ' {{.Name}} {{.CurrentState}}'" 2>/dev/null || true)
if [ -n "$OUTPUT" ]; then
echo "$OUTPUT"
break
+55
View File
@@ -0,0 +1,55 @@
# ─────────────────────────────────────────────────────────────────────────────
# ai.env.template — AUTHORITATIVE template for ai/ai.env (rendered by
# deploy/provision-stack.py per secrets/secrets-map.yaml).
#
# - This file IS the complete key list for ai.env. The whole file is
# rendered on every provisioning run — no line surgery, so a key can
# never silently go missing again (root cause of the 2026-09-03 outage).
# - Key names match EXACTLY what ai/ai.yaml references (AI_<SERVICE>_*
# naming adopted on main 2026-09-06).
# - Non-secret config lives here as LITERAL values (visible, reviewable).
# - Secret values are dollar-brace placeholders resolved from the CI env
# (Woodpecker from_secret vars) at provisioning time. provision-stack.py
# FAILS HARD if any placeholder is missing/empty.
# - The rendered ai/ai.env exists only on the host (gitignored).
# - Rendered by provision-stack.py, NOT Woodpecker's yaml preprocessor —
# single-dollar placeholders are safe here (deploy.yml's double-dollar
# rule does NOT apply to this file).
#
# Consumed by ai/ai.yaml. DOMAIN_NAME comes from deploy/global.env, not here.
# ─────────────────────────────────────────────────────────────────────────────
# ── LiteLLM (non-secret config) ──────────────────────────────────────────────
AI_AWS_REGION_NAME=us-east-2
AI_LITELLM_MODIFY_PARAMS=False
AI_LITELLM_DATABASE_MIGRATIONS=True
# ── LiteLLM (secrets) ────────────────────────────────────────────────────────
AI_AWS_ACCESS_KEY_ID=${AI_AWS_ACCESS_KEY_ID}
AI_AWS_SECRET_ACCESS_KEY=${AI_AWS_SECRET_ACCESS_KEY}
AI_LITELLM_MASTER_KEY=${AI_LITELLM_MASTER_KEY}
AI_LITELLM_SALT_KEY=${AI_LITELLM_SALT_KEY}
# Full connection URL is itself a secret (ai_litellm_database_url) — the
# URL structure never appears in git.
AI_LITELLM_DATABASE_URL=${AI_LITELLM_DATABASE_URL}
AI_LITELLM_POSTGRES_PASSWORD=${AI_LITELLM_POSTGRES_PASSWORD}
# ── Open WebUI (non-secret config) ───────────────────────────────────────────
AI_OPEN_WEBUI_URL=https://ai.bryanmail.net
AI_OPEN_WEBUI_ENABLE_OAUTH_SIGNUP=true
AI_OPEN_WEBUI_OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true
AI_OPEN_WEBUI_OAUTH_PROVIDER_NAME=Authentik
AI_OPEN_WEBUI_OPENID_PROVIDER_URL=https://auth.bryanmail.net/application/o/open-web-ui/.well-known/openid-configuration
# OAuth client ID is a public identifier by OAuth2 design (it is sent to the
# browser); the client SECRET below is the protected credential.
AI_OPEN_WEBUI_OAUTH_CLIENT_ID=hVmhi1dS3TnG2cUw5QwLOx5FDLSWtnQUdZyeB5zK
AI_OPEN_WEBUI_OAUTH_SCOPES=openid email profile
AI_OPEN_WEBUI_OPENID_REDIRECT_URI=https://ai.bryanmail.net/oauth/oidc/callback
# ── Open WebUI (secrets) ─────────────────────────────────────────────────────
AI_OPEN_WEBUI_SECRET_KEY=${AI_OPEN_WEBUI_SECRET_KEY}
AI_OPEN_WEBUI_DATABASE_URL=${AI_OPEN_WEBUI_DATABASE_URL}
AI_OPEN_WEBUI_OAUTH_CLIENT_SECRET=${AI_OPEN_WEBUI_OAUTH_CLIENT_SECRET}
# ── mcpo / mcpo-critical (secrets) ───────────────────────────────────────────
MCPO_API_KEY=${AI_MCPO_API_KEY}
+21 -20
View File
@@ -1,6 +1,6 @@
services:
open-webui:
image: ghcr.io/open-webui/open-webui:0.11.1
image: ghcr.io/open-webui/open-webui:0.11.3 #
entrypoint:
- /bin/bash
- /app/tools/startup.sh
@@ -16,20 +16,20 @@ services:
start_period: 60s
environment:
- OLLAMA_BASE_URL=http://ollama-intel-arc:11434
- WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY}
- WEBUI_SECRET_KEY=${AI_OPEN_WEBUI_SECRET_KEY}
- WEBUI_DB_HOST=postgresql
- DATABASE_URL=${OPEN_WEBUI_DATABASE_URL}
- DATABASE_URL=${AI_OPEN_WEBUI_DATABASE_URL}
- ENABLE_TOOL_SERVER_CALLING=true
- TOOL_SERVER_CALLING=true
- WEBUI_URL=${WEBUI_URL}
- ENABLE_OAUTH_SIGNUP=${ENABLE_OAUTH_SIGNUP}
- OAUTH_MERGE_ACCOUNTS_BY_EMAIL=${OAUTH_MERGE_ACCOUNTS_BY_EMAIL}
- OAUTH_PROVIDER_NAME=${OAUTH_PROVIDER_NAME}
- OPENID_PROVIDER_URL=${OPENID_PROVIDER_URL}
- OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID}
- OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET}
- OAUTH_SCOPES=${OAUTH_SCOPES}
- OPENID_REDIRECT_URI=${OPENID_REDIRECT_URI}
- WEBUI_URL=${AI_OPEN_WEBUI_URL}
- ENABLE_OAUTH_SIGNUP=${AI_OPEN_WEBUI_ENABLE_OAUTH_SIGNUP}
- OAUTH_MERGE_ACCOUNTS_BY_EMAIL=${AI_OPEN_WEBUI_OAUTH_MERGE_ACCOUNTS_BY_EMAIL}
- OAUTH_PROVIDER_NAME=${AI_OPEN_WEBUI_OAUTH_PROVIDER_NAME}
- OPENID_PROVIDER_URL=${AI_OPEN_WEBUI_OPENID_PROVIDER_URL}
- OAUTH_CLIENT_ID=${AI_OPEN_WEBUI_OAUTH_CLIENT_ID}
- OAUTH_CLIENT_SECRET=${AI_OPEN_WEBUI_OAUTH_CLIENT_SECRET}
- OAUTH_SCOPES=${AI_OPEN_WEBUI_OAUTH_SCOPES}
- OPENID_REDIRECT_URI=${AI_OPEN_WEBUI_OPENID_REDIRECT_URI}
networks:
- traefik_backend
- postgresql_db-backend
@@ -62,13 +62,13 @@ services:
environment:
- AWS_ACCESS_KEY_ID=${AI_AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AI_AWS_SECRET_ACCESS_KEY}
- AWS_REGION_NAME=${AWS_REGION_NAME}
- LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY}
- LITELLM_SALT_KEY=${LITELLM_SALT_KEY}
- DATABASE_URL=${DATABASE_URL}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- LITELLM_MODIFY_PARAMS=${LITELLM_MODIFY_PARAMS}
- LITELLM_DATABASE_MIGRATIONS=${LITELLM_DATABASE_MIGRATIONS}
- AWS_REGION_NAME=${AI_AWS_REGION_NAME}
- LITELLM_MASTER_KEY=${AI_LITELLM_MASTER_KEY}
- LITELLM_SALT_KEY=${AI_LITELLM_SALT_KEY}
- DATABASE_URL=${AI_LITELLM_DATABASE_URL}
- POSTGRES_PASSWORD=${AI_LITELLM_POSTGRES_PASSWORD}
- LITELLM_MODIFY_PARAMS=${AI_LITELLM_MODIFY_PARAMS}
- LITELLM_DATABASE_MIGRATIONS=${AI_LITELLM_DATABASE_MIGRATIONS}
networks:
- traefik_backend
- postgresql_db-backend
@@ -127,7 +127,7 @@ services:
labels:
- traefik.enable=false
mcpo:
image: git.bryanmail.net/homelab/flowagent-mcpo:916164714429
image: git.bryanmail.net/homelab/flowagent-mcpo:c5b22618404a
command:
- --config
- /app/config/config.json
@@ -141,6 +141,7 @@ services:
- /volume1/docker/mcpo/data:/mcpo_data
- /volume1/docker/cronicle/ssh_keys:/app/ssh_keys:ro
- /volume1/docker/mcpo/uv-cache:/app/uv-cache
- /volume1/docker/mcpo/flowagent-auth:/app/flowagent-auth
secrets:
- source: flowagent_azure_client_id
target: flowagent_azure_client_id
+260 -70
View File
@@ -5,10 +5,20 @@
# Behavior:
# - Clean + up to date -> pass silently
# - Clean + behind (ff-only) -> auto `git pull --ff-only`, then pass
# - Ahead only (unpushed) -> interactive: offer to push; non-interactive: BLOCK
# - Dirty tracked changes -> offer to commit + push right now
# (auto in non-interactive/CI runs, after a
# - Clean + ahead only -> interactive: offer to push; non-interactive: BLOCK
# (unpushed)
# - Unresolved merge conflict -> REFUSE immediately. Never auto-commits over
# markers present conflict markers. Prints remediation options.
# - Dirty + local in sync -> offer to commit + push right now
# with origin (auto in non-interactive/CI runs, after a
# secret-pattern scan of the staged diff)
# - Dirty + local STALE/ -> NEVER commit on top of a stale base. Stash
# diverged vs origin the dirty changes first, resync main with
# origin using the same behind/ahead/diverged
# rules as the clean-tree case, then reapply
# the stash and re-run. On any failure the
# stash is preserved and remediation options
# (with exact commands) are printed.
# - Diverged (local AND -> REFUSE. Never auto-resolves. Prints the
# remote both moved) backup/stash/reset recovery steps and exits.
#
@@ -36,12 +46,246 @@ git status --porcelain | grep -q . && DIRTY=1
SECRET_PATTERN='(-----BEGIN [A-Z]+ PRIVATE KEY-----|AKIA[0-9A-Z]{16}|xox[baprs]-[0-9a-zA-Z-]+|password[[:space:]]*[:=][[:space:]]*[^$ ]|api[_-]?key[[:space:]]*[:=][[:space:]]*[^$ ])'
# resync_with_origin <local_sha> <remote_sha> <base_sha>
#
# Handles the behind/ahead/diverged cases against a CLEAN working tree.
# Shared by both the "tree was already clean" path and the new
# "dirty tree turned out to be stale, so we stashed first" path, so the
# two paths can never drift out of sync with each other.
#
# Returns 0 if it's now safe to deploy, 1 if it could not safely resolve
# (guidance already printed to stdout in that case).
resync_with_origin() {
local local_sha="$1" remote_sha="$2" base_sha="$3"
# ---- Case: fully in sync ----
if [ "$local_sha" = "$remote_sha" ]; then
echo "==> In sync with origin/main ($local_sha). OK to deploy."
return 0
fi
# ---- Case: behind only (fast-forwardable) ----
if [ "$local_sha" = "$base_sha" ]; then
echo "!! Local main is behind origin/main."
if [ "$INTERACTIVE" -eq 1 ]; then
read -rp "Fast-forward pull now? [y/N] " ans
else
ans="y"
echo "(non-interactive session — auto fast-forwarding)"
fi
if [[ "$ans" =~ ^[Yy]$ ]]; then
git pull --ff-only origin main
echo "==> Fast-forwarded to $(git rev-parse --short main). OK to deploy."
return 0
else
echo "Aborting - pull manually, then retry:"
echo " cd $DIR && git pull --ff-only origin main"
return 1
fi
fi
# ---- Case: ahead only (local commits not yet pushed) ----
if [ "$remote_sha" = "$base_sha" ]; then
echo "!! Local main is AHEAD of origin/main (unpushed commits):"
git log --oneline "origin/main..main"
echo
if [ "$INTERACTIVE" -eq 1 ]; then
read -rp "Push local commits to origin/main now? [y/N] " ans
else
ans="n"
echo "(non-interactive session — will NOT auto-push ahead commits; needs human review)"
fi
if [[ "$ans" =~ ^[Yy]$ ]]; then
git push origin main
echo "==> Pushed. OK to deploy."
return 0
else
echo "Aborting. Review with:"
echo " cd $DIR && git log origin/main..main"
echo "Then push manually when ready:"
echo " git push origin main"
return 1
fi
fi
# ---- Case: true divergence (both ahead and behind) — NEVER auto-fix ----
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo "!! DIVERGED: local main and origin/main have both moved independently."
echo "!!"
echo "!! Local-only commits:"
git log --oneline "$base_sha..main" | sed 's/^/!! /'
echo "!!"
echo "!! Remote-only commits:"
git log --oneline "$base_sha..origin/main" | sed 's/^/!! /'
echo "!!"
echo "!! This requires a human decision - git-guard will NOT auto-resolve this."
echo "!!"
echo "!! Before stashing anything new, check whether a git-guard safety stash"
echo "!! ALREADY exists from this same run (avoids confusing duplicate stashes):"
echo "!! git stash list"
echo "!!"
echo "!! Recommended recovery:"
echo "!! 1. tar backup: tar czf /volume1/docker/compose-files-backup-\$(date +%Y%m%d-%H%M%S).tar.gz -C /volume1/docker compose-files"
echo "!! 2. name the branch: git branch backup/pre-reset-\$(date +%Y%m%d)"
echo "!! 3. stash any NEW uncommitted state only if 'git stash list' above"
echo "!! didn't already show one for this run:"
echo "!! git stash push -u -m 'pre-reset-snapshot'"
echo "!! 4. reset to origin: git reset --hard origin/main"
echo "!! 5. selectively restore needed files from the stash/backup branch:"
echo "!! git stash list"
echo "!! git stash show -p stash@{N}"
echo "!! git stash apply stash@{N} # 'apply' keeps the stash as a backup; use 'pop' to also drop it"
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
return 1
}
# ---- Case: unresolved merge conflict already present ----
# Can happen if a PRIOR git-guard run's `git stash pop` conflicted and the
# resulting conflict markers were never resolved before the next deploy
# attempt. Must be checked BEFORE the dirty-tree commit flow below, because
# an unmerged path shows up as "dirty" too, and `git add -A` would silently
# stage the literal <<<<<<< / ======= / >>>>>>> markers into a real commit.
if git ls-files -u | grep -q .; then
echo "ERROR: unresolved merge conflict markers present in the working tree."
echo "Refusing to auto-commit over a conflict — this would push literal"
echo "<<<<<<< / ======= / >>>>>>> markers to origin/main."
echo
echo "Conflicted paths:"
git diff --name-only --diff-filter=U | sed 's/^/ /'
echo
echo "Remediation options:"
echo " A) Resolve the conflict by hand, then commit and push:"
echo " cd $DIR"
echo " git status # see conflicted paths"
echo " git diff # inspect the conflict markers"
echo " \$EDITOR <conflicted-file> # remove markers, keep correct content"
echo " git add <conflicted-file>"
echo " git commit -m 'resolve git-guard stash-pop conflict'"
echo " git push origin main"
echo " B) Discard the conflicted merge attempt entirely and start clean from"
echo " origin/main, then decide separately whether to re-apply anything"
echo " from a prior safety stash:"
echo " cd $DIR"
echo " git checkout -- ."
echo " git reset --hard origin/main"
echo " git stash list # look for a git-guard-safety-stash-* entry"
echo " git stash show -p stash@{N} # inspect before deciding"
echo " C) Once resolved (via A or B) and no longer needed, clean up the stash:"
echo " git stash drop stash@{N}"
exit 1
fi
# ---- Case: dirty tracked changes ----
if [ "$DIRTY" -eq 1 ]; then
echo "!! WORKING TREE DIRTY — uncommitted changes detected:"
git status --short
echo
# If local is ALSO stale/diverged from origin, committing right now would
# create a doomed commit on top of a base that's about to be rejected on
# push (this is exactly what caused a real incident: a stray on-disk edit
# sat on a checkout that was 4 commits behind, git-guard auto-committed
# anyway, then the push bounced). Stash first, resync safely using the
# same rules as the clean-tree path, then reapply.
if [ "$LOCAL" != "$REMOTE" ]; then
echo "!! Local main is ALSO stale/diverged from origin/main."
echo " Refusing to commit on top of a stale base — stashing the dirty"
echo " changes safely first, then resyncing with origin."
echo
STASH_MSG="git-guard-safety-stash-$(date -u +%Y%m%dT%H%M%SZ)"
if ! git stash push -u -m "$STASH_MSG"; then
echo "ERROR: 'git stash push' itself failed (disk full, permissions, or"
echo " some other git error). Your changes are still on disk,"
echo " uncommitted — nothing has been lost, but git-guard cannot"
echo " proceed safely until this is resolved."
echo
echo "Remediation options:"
echo " A) Check disk space and permissions, then retry the deploy:"
echo " df -h $DIR"
echo " ls -la $DIR"
echo " B) Identify and manually move aside whatever is blocking the stash,"
echo " then retry:"
echo " cd $DIR"
echo " git status --short # find the offending path(s)"
echo " mv <path> <path>.bak-\$(date +%s)"
echo " C) Inspect the raw git error above for specifics before proceeding."
exit 1
fi
echo "==> Stashed as: $STASH_MSG"
if resync_with_origin "$LOCAL" "$REMOTE" "$BASE"; then
echo "==> Resync succeeded. Reapplying stashed changes..."
if git stash pop; then
echo "==> Stash reapplied cleanly. Re-checking sync state..."
exec bash "$0" "$@"
else
echo "ERROR: 'git stash pop' did not complete successfully."
echo
if git ls-files -u | grep -q .; then
echo "This is a MERGE CONFLICT — your stashed changes were partially"
echo "applied and conflict markers (<<<<<<< / ======= / >>>>>>>) are now"
echo "in the working tree. The stash itself is still preserved as a backup."
echo
echo "Remediation options:"
echo " A) Resolve the conflict by hand, then commit and push:"
echo " cd $DIR"
echo " git status # see conflicted paths"
echo " git diff # inspect the markers"
echo " \$EDITOR <conflicted-file> # remove markers, keep correct content"
echo " git add <conflicted-file>"
echo " git commit -m 'resolve git-guard stash-pop conflict'"
echo " git push origin main"
echo " git stash list # confirm which entry is: $STASH_MSG"
echo " git stash drop stash@{N} # once confirmed no longer needed"
echo " B) Abandon this merge attempt and fall back to a clean, resynced"
echo " tree, then re-apply the change manually with full visibility:"
echo " cd $DIR"
echo " git checkout -- ."
echo " git reset --hard origin/main # now matches origin, no conflict"
echo " git stash list # find: $STASH_MSG"
echo " git stash show -p stash@{N} # review the content"
echo " git stash apply stash@{N} # 'apply' keeps the backup; use 'pop' to also drop it"
else
echo "This looks like an UNTRACKED-FILE COLLISION, not a merge conflict"
echo "(a file added upstream shares a path with an untracked file in your"
echo "stash). No conflict markers were written; the stash was NOT applied"
echo "and remains fully intact."
echo
echo "Remediation options:"
echo " A) Move the colliding upstream file aside, pop, then reconcile:"
echo " cd $DIR"
echo " git status --short # identify the colliding path"
echo " mv <path> <path>.upstream-\$(date +%s)"
echo " git stash pop"
echo " diff <path> <path>.upstream-* # reconcile manually, then remove the .upstream-* backup"
echo " B) Inspect the stash without applying, and hand-merge the needed"
echo " pieces instead:"
echo " git stash list # find N"
echo " git stash show -p stash@{N}"
fi
echo
echo "Your stash reference for this run: $STASH_MSG"
exit 1
fi
else
echo "ERROR: could not safely resync with origin/main."
echo "Your uncommitted changes are preserved in the stash: $STASH_MSG"
echo
echo "Remediation options:"
echo " A) Follow the manual recovery steps printed above (from the"
echo " behind/ahead/diverged case), THEN reapply your change:"
echo " cd $DIR"
echo " git stash list # find: $STASH_MSG"
echo " git stash apply stash@{N} # or 'pop' to also drop it once resynced"
echo " B) If the stashed change is no longer needed (e.g. it's already"
echo " represented in a since-merged PR), verify then drop it:"
echo " git stash show -p stash@{N}"
echo " git stash drop stash@{N}"
exit 1
fi
fi
if [ "$INTERACTIVE" -eq 1 ]; then
read -rp "Commit and push these changes to origin/main now? [y/N] " ans
else
@@ -54,7 +298,8 @@ if [ "$DIRTY" -eq 1 ]; then
if git diff --cached | grep -Eiq "$SECRET_PATTERN"; then
echo "ERROR: possible secret detected in staged changes. Refusing to auto-commit."
echo "Review manually: git diff --cached"
echo "Review manually:"
echo " cd $DIR && git diff --cached"
git reset
exit 1
fi
@@ -63,80 +308,25 @@ if [ "$DIRTY" -eq 1 ]; then
if git push origin main; then
echo "==> Pushed. Re-checking sync state..."
exec "$0" "$@"
exec bash "$0" "$@"
else
echo "ERROR: push failed (likely diverged from origin). Aborting deploy."
echo "Run: cd $DIR && git status"
echo "Run:"
echo " cd $DIR && git status"
exit 1
fi
else
echo "Aborting deploy - commit or stash changes manually, then retry."
echo "Aborting deploy - commit or stash changes manually, then retry:"
echo " cd $DIR"
echo " git add -A && git commit -m 'your message' && git push origin main"
echo " # or: git stash push -u -m 'manual-stash'"
exit 1
fi
fi
# ---- Case: fully in sync ----
if [ "$LOCAL" = "$REMOTE" ]; then
echo "==> In sync with origin/main ($LOCAL). OK to deploy."
# ---- Clean tree: resync with origin using the shared logic above ----
if resync_with_origin "$LOCAL" "$REMOTE" "$BASE"; then
exit 0
else
exit 1
fi
# ---- Case: behind only (fast-forwardable) ----
if [ "$LOCAL" = "$BASE" ]; then
echo "!! Local main is behind origin/main."
if [ "$INTERACTIVE" -eq 1 ]; then
read -rp "Fast-forward pull now? [y/N] " ans
else
ans="y"
echo "(non-interactive session — auto fast-forwarding)"
fi
if [[ "$ans" =~ ^[Yy]$ ]]; then
git pull --ff-only origin main
echo "==> Fast-forwarded to $(git rev-parse --short main). OK to deploy."
exit 0
else
echo "Aborting deploy - pull manually, then retry."
exit 1
fi
fi
# ---- Case: ahead only (local commits not yet pushed) ----
if [ "$REMOTE" = "$BASE" ]; then
echo "!! Local main is AHEAD of origin/main (unpushed commits):"
git log --oneline "origin/main..main"
echo
if [ "$INTERACTIVE" -eq 1 ]; then
read -rp "Push local commits to origin/main now? [y/N] " ans
else
ans="n"
echo "(non-interactive session — will NOT auto-push ahead commits; needs human review)"
fi
if [[ "$ans" =~ ^[Yy]$ ]]; then
git push origin main
echo "==> Pushed. OK to deploy."
exit 0
else
echo "Aborting deploy. Review with: git log origin/main..main"
exit 1
fi
fi
# ---- Case: true divergence (both ahead and behind) — NEVER auto-fix ----
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo "!! DIVERGED: local main and origin/main have both moved independently."
echo "!!"
echo "!! Local-only commits:"
git log --oneline "$BASE..main" | sed 's/^/!! /'
echo "!!"
echo "!! Remote-only commits:"
git log --oneline "$BASE..origin/main" | sed 's/^/!! /'
echo "!!"
echo "!! This requires a human decision - git-guard will NOT auto-resolve this."
echo "!! Recommended recovery:"
echo "!! 1. tar backup: tar czf /volume1/docker/compose-files-backup-\$(date +%Y%m%d-%H%M%S).tar.gz -C /volume1/docker compose-files"
echo "!! 2. name the branch: git branch backup/pre-reset-\$(date +%Y%m%d)"
echo "!! 3. stash all state: git stash push -u -m 'pre-reset-snapshot'"
echo "!! 4. reset to origin: git reset --hard origin/main"
echo "!! 5. selectively restore needed files from the stash/backup branch"
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
exit 1
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""provision-stack.py — manifest-driven env-file rendering + Docker secret
provisioning for one stack.
Usage (from the CI workspace root, inside the provision-secrets step):
python3 deploy/provision-stack.py <stack>
Reads secrets/secrets-map.yaml (data only — no code, no values) and, for the
named stack:
1. env_template -> renders the COMPLETE env file. Placeholders of the form
dollar-brace VARNAME are resolved from this process's environment (the
Woodpecker from_secret-backed vars). The whole file is rendered every
run; nothing is line-edited in place, so keys can never silently go
missing (root cause of the 2026-09-03 ai.env incident).
2. env_dest -> ships the rendered file to the Swarm manager over ssh stdin
(write-to-temp + atomic mv, mode 600). The rendered file never touches
the CI workspace disk under the repo (no chance of being committed) and
never appears on a command line.
3. docker_secrets -> for each swarm-secret-name -> ENV_VAR mapping, creates
or rotates the Docker secret. Values are passed via ssh stdin only.
Rotation uses the same sha256-checksum-label convention as
deploy/create-secrets.sh (unchanged secrets are skipped silently).
Safety properties:
- FAILS HARD (non-zero) if any referenced env var is missing or empty, and
lists the missing NAMES. A partial/broken render can never ship.
- FAILS HARD if any unresolved placeholder remains after rendering.
- NEVER prints a secret value — names and counts only.
- Requires SWARM_MANAGER_IP in the environment and a usable ssh identity
(both already set up by the provision-secrets step).
Stacks not present in the manifest exit 0 with a notice, so this script is
safe to call unconditionally; legacy case-entries in deploy.yml keep handling
unmigrated stacks.
"""
import hashlib
import os
import re
import subprocess
import sys
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MANIFEST_PATH = os.path.join(REPO_ROOT, "secrets", "secrets-map.yaml")
REMOTE_BASE = "/volume1/docker/compose-files"
PLACEHOLDER_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
SSH_OPTS = ["-o", "StrictHostKeyChecking=no"]
def die(msg: str) -> None:
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def load_manifest() -> dict:
try:
import yaml # py3-yaml, installed by the provision-secrets step
except ImportError:
die("PyYAML not available — provision-secrets step must apk add py3-yaml")
if not os.path.isfile(MANIFEST_PATH):
die(f"manifest not found: {MANIFEST_PATH}")
with open(MANIFEST_PATH, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
stacks = data.get("stacks")
if not isinstance(stacks, dict):
die("manifest has no 'stacks:' mapping")
return stacks
def ssh_target() -> str:
ip = os.environ.get("SWARM_MANAGER_IP", "").strip()
if not ip:
die("SWARM_MANAGER_IP is empty — check Woodpecker repo secrets")
return f"root@{ip}"
def ssh_run(target: str, remote_cmd: str, stdin_data: bytes | None = None,
check: bool = True) -> subprocess.CompletedProcess:
proc = subprocess.run(
["ssh", *SSH_OPTS, target, remote_cmd],
input=stdin_data, capture_output=True,
)
if check and proc.returncode != 0:
# stderr may be verbose but must never contain our secret values —
# we only ever send values via stdin, never embed them in remote_cmd.
die(f"remote command failed (rc={proc.returncode}): {remote_cmd}\n"
f"{proc.stderr.decode(errors='replace').strip()}")
return proc
def render_template(template_path: str) -> str:
if not os.path.isfile(template_path):
die(f"env_template not found: {template_path}")
with open(template_path, "r", encoding="utf-8") as fh:
raw = fh.read()
referenced = sorted(set(PLACEHOLDER_RE.findall(raw)))
missing = [v for v in referenced
if not os.environ.get(v, "").strip()]
if missing:
die("template references vars that are MISSING or EMPTY in the CI "
"environment (add them via from_secret in "
".woodpecker/deploy.yml provision-secrets, and as Woodpecker "
"secrets):\n " + "\n ".join(missing))
rendered = PLACEHOLDER_RE.sub(lambda m: os.environ[m.group(1)], raw)
# Belt-and-braces: nothing placeholder-shaped may survive the render.
leftover = sorted(set(PLACEHOLDER_RE.findall(rendered)))
if leftover:
die("unresolved placeholders remain after rendering: "
+ ", ".join(leftover))
print(f" [render] {template_path}: {len(referenced)} secret placeholder(s) "
f"resolved: {', '.join(referenced)}")
return rendered
def ship_env_file(target: str, rendered: str, dest_rel: str) -> None:
dest = f"{REMOTE_BASE}/{dest_rel}"
tmp = f"{dest}.provision-tmp"
# Value travels over ssh stdin; never on a command line; atomic mv.
ssh_run(target,
f"umask 077 && cat > {tmp} && chmod 600 {tmp} && mv {tmp} {dest}",
stdin_data=rendered.encode())
keys = [ln.split("=", 1)[0] for ln in rendered.splitlines()
if "=" in ln and not ln.lstrip().startswith("#") and ln.strip()]
print(f" [env] shipped {dest} ({len(keys)} keys): {', '.join(keys)}")
def provision_docker_secret(target: str, name: str, env_var: str) -> None:
value = os.environ.get(env_var, "")
if not value.strip():
die(f"docker secret '{name}': env var {env_var} is missing/empty")
new_hash = hashlib.sha256(value.encode()).hexdigest()
probe = ssh_run(
target,
f"docker secret inspect {name} "
"--format '{{index .Spec.Labels \"checksum\"}}' 2>/dev/null || true",
check=True)
old_hash = probe.stdout.decode().strip()
if old_hash == new_hash:
print(f" [skip] docker secret {name} (unchanged)")
return
if old_hash:
rm = ssh_run(target, f"docker secret rm {name}", check=False)
if rm.returncode != 0:
die(f"docker secret {name}: value changed but removal failed — "
"it is probably referenced by a running service. Provision "
"under a versioned name (see vaultwarden_database_url_v2 "
"precedent) or scale the service down first.")
action = "update"
else:
action = "create"
ssh_run(target,
f"docker secret create --label checksum={new_hash} "
f"--label managed-by=woodpecker {name} -",
stdin_data=value.encode())
print(f" [{action}] docker secret {name} (value via stdin)")
def main() -> None:
if len(sys.argv) != 2:
die("usage: provision-stack.py <stack>")
stack = sys.argv[1]
stacks = load_manifest()
cfg = stacks.get(stack)
if cfg is None:
print(f" [info] stack '{stack}' not in secrets-map.yaml — "
"legacy provisioning (deploy.yml case-entry) applies. Nothing to do.")
return
target = ssh_target()
print(f"==> provision-stack: {stack}")
template_rel = cfg.get("env_template")
dest_rel = cfg.get("env_dest")
if template_rel and not dest_rel:
die("env_template set but env_dest missing in manifest")
if template_rel:
rendered = render_template(os.path.join(REPO_ROOT, template_rel))
ship_env_file(target, rendered, dest_rel)
for name, env_var in (cfg.get("docker_secrets") or {}).items():
provision_docker_secret(target, name, env_var)
print(f"==> provision-stack: {stack} done")
if __name__ == "__main__":
main()
+9
View File
@@ -63,6 +63,15 @@ services:
FRIGATE_RTSP_PASSWORD: "${FRIGATE_RTSP_PASSWORD}"
networks:
- traefik_backend
ports:
- target: 8555
published: 8555
protocol: udp
mode: ingress
- target: 8555
published: 8555
protocol: tcp
mode: ingress
deploy:
labels:
- traefik.enable=true
+18 -5
View File
@@ -6,9 +6,9 @@ This directory documents the secrets required for each Docker Swarm stack.
```
Secret values live in Woodpecker (encrypted)
pipeline reads them at deploy time
pipeline reads them at deploy time
Docker Swarm secret store (encrypted Raft DB, replicated across all nodes)
mounted into containers at runtime
mounted into containers at runtime
/run/secrets/<secret_name>
```
@@ -38,7 +38,10 @@ Use the stack's `.secrets.example` file as your checklist.
### Step 2 — Add the stack's case to `.woodpecker.yml`
In the `provision-secrets` step, add a case for the stack that calls
`create_or_update_secret` for each secret.
`create_or_update_secret` for each secret. (Or, preferred for new
migrations: add an entry to `secrets/secrets-map.yaml` + a
`<stack>.env.template` and call `deploy/provision-stack.py <stack>`
instead — see the `ai` and `traefik` entries for the current pattern.)
### Step 3 — Test by pushing a trivial change to the stack's yaml file
Watch the pipeline run: provision-secrets → validate → deploy → verify → notify.
@@ -71,10 +74,20 @@ Examples:
- **PostgreSQL is highest risk.** Its master password is used by nearly every
other stack. Migrate it last.
- **Never commit a rendered env file, even by accident.** `traefik/traefik.env`
was committed to git for a period (discovered/fixed 2026-09-12) with a
literal "***REDACTED***" placeholder as KEEPALIVED_PASSWORD, which was
silently restored every time the file was deleted or the checkout resynced
from git — causing a real VRRP auth outage. `.gitignore` blanket-excludes
`*.env`, but that rule does NOT retroactively untrack a file already
committed before the rule existed. If you ever see a stack's `.env` file
show up in `git status` as tracked, stop and untrack it (`git rm --cached`)
before doing anything else.
## Migration Status
| Stack | Secrets in Woodpecker | Pipeline Step Added | .env Removed |
|-------|----------------------|---------------------|--------------|
|-------|------------------------|----------------------|---------------|
| 3dprint | ⏳ | ⏳ | ⏳ |
| ai | ⏳ | ⏳ | ⏳ |
| auth | ⏳ | ⏳ | ⏳ |
@@ -91,7 +104,7 @@ Examples:
| postgresql | ⏳ | ⏳ | ⏳ |
| productivity | ⏳ | ⏳ | ⏳ |
| security | ⏳ | ⏳ | ⏳ |
| traefik | ⏳ | ⏳ | ⏳ |
| traefik | ✅ (manifest-driven, 2026-09-12) | ✅ (manifest-driven, 2026-09-12) | ⏳ |
| unifi | ✅ N/A (no secrets) | ✅ N/A | ⏳ |
| vaultwarden | ⏳ | ⏳ | ⏳ |
| woodpecker | ⏳ Manual only | ⏳ N/A | ⏳ |
+41
View File
@@ -0,0 +1,41 @@
# ─────────────────────────────────────────────────────────────────────────
# secrets-map.yaml — DATA-ONLY manifest for deploy/provision-stack.py
#
# RULES:
# - This file contains NO code, NO shell, NO secret values — only names.
# - Each stack entry declares:
# env_template: repo path of the FULL env-file template (tracked).
# The template is authoritative: the COMPLETE env file is
# rendered from it on every provisioning run. Nothing is
# line-edited in place, so keys can never silently go
# missing.
# env_dest: host path (relative to /volume1/docker/compose-files/)
# the rendered env file is shipped to. Rendered file
# exists ONLY on the host — never committed to git.
# docker_secrets: map of docker-swarm-secret-name -> CI ENV VAR NAME
# (Pattern C). The env var must be declared via
# from_secret: in .woodpecker/deploy.yml's
# provision-secrets step (Woodpecker v3 requires explicit
# per-secret declaration; there is no expose-all).
#
# ADDING A NEW SECRET (3 small steps, no shell edits):
# 1. Add the secret value in Woodpecker UI (repo Settings -> Secrets).
# 2. Declare it in .woodpecker/deploy.yml provision-secrets environment:
# block (from_secret) — mechanical two-line addition.
# 3. Reference it here (docker_secrets:) and/or in the stack's
# .env.template as a dollar-brace placeholder.
#
# Stacks not listed here fall through to deploy.yml's legacy case-entries
# untouched. Migration is deliberately one stack per PR.
# ─────────────────────────────────────────────────────────────────────────
stacks:
ai:
env_template: ai/ai.env.template
env_dest: ai/ai.env
docker_secrets:
flowagent_azure_client_id: FLOWAGENT_AZURE_CLIENT_ID
flowagent_azure_tenant_id: FLOWAGENT_AZURE_TENANT_ID
flowagent_azure_client_secret: FLOWAGENT_AZURE_CLIENT_SECRET
traefik:
env_template: traefik/traefik.env.template
env_dest: traefik/traefik.env
+33 -13
View File
@@ -1,5 +1,8 @@
# traefik Stack — Secrets Reference
# Source: traefik.env
# Source: traefik.env.template (rendered by deploy/provision-stack.py per
# secrets/secrets-map.yaml — see secrets-map.yaml header for how
# this works). traefik/traefik.env is rendered fresh on every
# provisioning run and is NEVER committed to git.
#
# Add SECRET values to Woodpecker at:
# https://woodpecker.bryanmail.net
@@ -8,27 +11,44 @@
# ⚠️ HIGH RISK: Traefik is the entry point for all homelab services.
# If this stack fails, nothing is reachable from outside.
# Migrate carefully. The Keepalived VIP (192.168.4.30) depends on this stack.
#
# ⚠️ 2026-09-12 INCIDENT: traefik.env was previously committed to git with a
# literal "***REDACTED***" placeholder as KEEPALIVED_PASSWORD. Every
# git-guard resync/checkout restored that broken value onto disk,
# diverging from keepalived-backup's stale-but-correct in-memory value
# and causing continuous VRRP auth failures + VIP instability. This was
# the trigger for migrating this stack to Pattern C. If you ever see
# KEEPALIVED_PASSWORD as a literal placeholder-looking string on disk
# again, do NOT hand-edit it — check `git log traefik/` for a stray
# commit and fix the template in Gitea instead.
# ── SECRETS (add to Woodpecker) ───────────────────────────────────────────────
# ── SECRETS (add to Woodpecker) ─────────────────────────────────────────
# Woodpecker secret name: traefik_keepalived_password
# Used for: Keepalived VRRP authentication password
# Must match across all 3 nodes (docker-1, docker-2, docker-3)
# Env var in .env: KEEPALIVED_PASSWORD
# Used for: Keepalived VRRP authentication password
# Must match across all 3 nodes (docker-1, docker-2, docker-3)
# NOTE: classic VRRP simple-auth is silently
# truncated to 8 chars by keepalived itself — keep
# the value <= 8 characters, or be aware only the
# first 8 are actually significant on the wire.
# Env var in .env.template: KEEPALIVED_PASSWORD (via TRAEFIK_KEEPALIVED_PASSWORD)
traefik_keepalived_password=
# ── NON-SECRETS (safe in compose file or .env) ────────────────────────────────
# ── NON-SECRETS (safe in compose file or .env.template) ─────────────────
# KEEPALIVED_UNICAST_PEERS Python2BASH list of peer IPs
# KEEPALIVED_VIRTUAL_IPS Python2BASH list of VIP addresses (192.168.4.30)
# KEEPALIVED_VIRTUAL_IPS Python2BASH list of VIP addresses (192.168.4.30) — literal in template
# ACME_EMAIL Let's Encrypt certificate email
# TRUSTED_IPS Trusted proxy CIDR ranges
# TRAEFIK_HOST Traefik dashboard hostname
# SPEEDTEST_HOST Speedtest Traefik hostname
# SPEEDTEST_HOST Speedtest Traefik hostname
# WHOAMI_HOST Whoami Traefik hostname
# ── Woodpecker provision-secrets case entry ───────────────────────────────────
# ── Provisioning (manifest-driven, deploy/provision-stack.py) ───────────
#
# traefik)
# create_or_update_secret "traefik_keepalived_password" "$TRAEFIK_KEEPALIVED_PASSWORD"
# ;;
# This stack is migrated — provisioning happens automatically via:
# secrets/secrets-map.yaml (traefik: entry)
# traefik/traefik.env.template (authoritative key list)
# deploy/provision-stack.py (renders + ships traefik/traefik.env)
#
# The .woodpecker/deploy.yml provision-secrets step calls this with a
# single line: `python3 deploy/provision-stack.py traefik`
-2
View File
@@ -1,2 +0,0 @@
KEEPALIVED_PASSWORD=***REDACTED***
KEEPALIVED_VIRTUAL_IPS="#PYTHON2BASH:['192.168.4.30']"
+52
View File
@@ -0,0 +1,52 @@
# ─────────────────────────────────────────────────────────────────────────
# traefik.env.template — AUTHORITATIVE template for traefik/traefik.env
# (rendered by deploy/provision-stack.py per secrets/secrets-map.yaml)
#
# - This file IS the complete key list for traefik.env. The whole file is
# rendered on every provisioning run — no line surgery, so a key can
# never silently go missing.
# - Non-secret config lives here as LITERAL values (visible, reviewable).
# - Secret values are dollar-brace placeholders resolved from the CI env
# (Woodpecker from_secret vars) at provisioning time. provision-stack.py
# FAILS HARD if any placeholder is missing/empty.
# - The rendered traefik/traefik.env exists only on the host (gitignored).
# - Rendered by provision-stack.py, NOT Woodpecker's yaml preprocessor —
# single-dollar placeholders are safe here (deploy.yml's double-dollar
# rule does NOT apply to this file).
#
# Consumed by traefik/traefik.yaml (KEEPALIVED_PASSWORD, KEEPALIVED_VIRTUAL_IPS).
# DOMAIN comes from deploy/global.env, not here.
#
# ⚠️ HIGH RISK: Traefik is the entry point for all homelab services, and the
# Keepalived VIP (192.168.4.30) depends on this stack. KEEPALIVED_PASSWORD
# MUST be identical across keepalived-master and keepalived-backup — both
# consume this same rendered value — or VRRP auth fails and the VIP
# becomes unstable (this is exactly what the 2026-09-12 incident was:
# a committed literal "***REDACTED***" placeholder in git, restored every
# time the file was deleted/re-synced, diverging from -backup's stale but
# correct in-memory value).
#
# Classic VRRP simple-auth is silently truncated to 8 characters by
# keepalived itself. Set TRAEFIK_KEEPALIVED_PASSWORD in Woodpecker to a
# value 8 characters or fewer (or accept that only the first 8 chars are
# actually significant) so the effective negotiated value is unambiguous.
#
# 2026-09-12 RE-TRIGGER: traefik.env was found MISSING on disk after the
# Pattern C migration merged — the manifest-driven provisioning render only
# happens on a push that changes traefik/, and traefik is bootstrap-tier
# (stack-deploy.sh must be run manually; the pipeline never auto-deploys
# this stack). A manual `stack-deploy.sh traefik` had apparently been run
# while traefik.env was absent, causing Swarm's own interpolation to
# silently render KEEPALIVED_PASSWORD/KEEPALIVED_VIRTUAL_IPS as EMPTY
# STRINGS on keepalived-master (not missing — empty, which is worse,
# since it's a valid-looking VRRP auth string that mismatches everything).
# This comment-only change re-triggers provision-secrets to re-render and
# re-ship traefik.env; a manual `stack-deploy.sh traefik` must still be run
# afterward to actually apply it to the running services.
# ─────────────────────────────────────────────────────────────────────────
# ── Keepalived (non-secret config) ────────────────────────────────────────
KEEPALIVED_VIRTUAL_IPS="#PYTHON2BASH:['192.168.4.30']"
# ── Keepalived (secret) ────────────────────────────────────────────────────
KEEPALIVED_PASSWORD=${TRAEFIK_KEEPALIVED_PASSWORD}