Author SHA1 Message Date
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
7 changed files with 650 additions and 151 deletions
+69 -61
View File
@@ -48,6 +48,28 @@ 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).
# ─────────────────────────────────────────────────────────────────────────────
steps:
@@ -65,9 +87,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 +185,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,14 +198,18 @@ 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:
@@ -186,7 +217,7 @@ steps:
FLOWAGENT_AZURE_CLIENT_SECRET:
from_secret: flowagent_azure_client_secret
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,14 +230,14 @@ 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
@@ -233,9 +264,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 +277,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 +326,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 +371,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 +379,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 +432,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 +443,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 +455,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
+38
View File
@@ -0,0 +1,38 @@
# ─────────────────────────────────────────────────────────────────────────────
# 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