Author SHA1 Message Date
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
Bot a15e0100df provision-secrets: add flowagent_* case-entry to the ai) stack (matched pair with ai.yaml's mcpo cutover)
Surgical addition to the existing case "ai)" block only — every other
case, comment, and line in this ~26KB pipeline file is byte-for-byte
unchanged from main. Given this file's documented incident history from
prior full-file rewrites (see header comment), this was hand-verified
against the current main content line-by-line before writing, not
generated fresh.

Adds:
  - 3 new environment: entries (FLOWAGENT_AZURE_CLIENT_ID/TENANT_ID/
    CLIENT_SECRET) via from_secret, alongside the existing ai_* secrets
  - Inside the existing `ai)` case, after the ai.env rewrite block: three
    create_or_update_secret calls provisioning flowagent_azure_client_id,
    flowagent_azure_tenant_id, flowagent_azure_client_secret as native
    Docker Swarm secrets (Pattern C, _FILE convention) — NOT written into
    ai/ai.env, since mcpo's flowagent entry reads them via
    /run/secrets/flowagent_azure_* per flowagent/entrypoint.sh, not env vars
  - A short comment explaining why these three are Pattern C secrets
    inside an otherwise Pattern-B (host .env) stack case

This is the matched-pair companion to the ai.yaml commit in this same PR,
per the FlowAgent progress note's explicit sequencing requirement (both
land together, reviewed, not auto-merged to main).
2026-09-02 16:53:10 -07:00
Bot bcfb24b16d ai.yaml: cut mcpo over to the confirmed-working flowagent-mcpo image + wire flowagent Docker secrets (never touches mcpo-critical)
Image pinned to git.bryanmail.net/homelab/flowagent-mcpo:916164714429 —
the exact sha-tagged image built and smoke-tested clean in mcp-config
pipeline #22 (build-and-push-flowagent + smoke-test-flowagent-image both
success). NOT :latest, for reproducible deploys.

Adds a secrets: block to the mcpo service only, referencing the 3
flowagent_azure_* Docker Swarm secrets (Pattern C, _FILE convention,
consumed by flowagent/entrypoint.sh via /run/secrets/flowagent_azure_*).
Declares those 3 secrets as external at top level, mirroring the existing
vaultwarden_admin_token_v2 pattern in vaultwarden.yaml.

Companion secret-provisioning change lands in the same PR as a separate
commit in .woodpecker/deploy.yml's provision-secrets step (case "ai)").

mcpo-critical (lines defining Proxmox access) is completely untouched —
still stock ghcr.io/open-webui/mcpo:main, no secrets: block, unchanged.

Per FlowAgent MCP progress note: Azure AD App Registration is reported
created with real secrets already in Woodpecker (both mcp-config and
compose-files repos) as of this PR. This is a reviewed PR against a
feature branch, NOT auto-merged to main — deploy only happens after
manual review/merge, per user's explicit request for a manual-revert
safety net on this high-blast-radius shared stack (LiteLLM, Open WebUI,
n8n also live here).
2026-09-02 16:51:31 -07:00
Bot 42d31e38e4 git stack: switch provision-secrets to Pattern B .env rewrite (test phase) + add git to bootstrap-tier guard
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
Context: git hosts the source of truth for every other stack's compose
files, so in a disaster-recovery scenario it must be restorable from a
flat git.yaml + git.env backup alone, with zero dependency on a running
Swarm's Docker secret store (native Docker secrets can't be resolved
until Swarm already exists, which is the circular dependency git.yaml
would otherwise create). Path 2 (documented decision): Pattern B for
git specifically, same mechanism the ai stack already uses.

provision-secrets / git) case:
  - Previously created native Docker secrets (git_db_password,
    git_runner_token, git_mcp_access_token) via create-secrets.sh —
    leftover from an earlier, abandoned Pattern C attempt.
  - Now rewrites only the secret-bearing lines (GITEA__database__PASSWD,
    GITEA_RUNNER_REGISTRATION_TOKEN, GITEA_MCP_ACCESS_TOKEN) via
    grep -v + printf, mirroring the ai) case exactly. Also strips the
    legacy GITEA_ACCESS_TOKEN key name so the test file converges on the
    git.env.example-documented key.
  - TEST PHASE: target is git.env.pipelinetest, NOT git.env. The real
    git.env is never opened for writing by this step. First run seeds
    the test file from the real git.env (to carry over all non-secret
    lines), then only the 3 secret lines are refreshed on every push.
  - Real cutover (pointing git.yaml/stack-deploy at the generated file,
    then retiring git.env.pipelinetest) is a deliberate follow-up step
    after manually diffing the rendered output.

deploy / bootstrap-tier guard:
  - Added git to the traefik|woodpecker|postgresql|secrets guard list.
    git.yaml changes now sync to the host but require a manual
    `stack-deploy.sh git` run, same as the other foundational stacks —
    prevents an auto-deploy of a bad git.yaml change from taking down
    Gitea before a human can look at it (Gitea itself is what every
    other pipeline needs to trigger a fix).
2026-09-02 00:02:36 -07:00
5 changed files with 398 additions and 64 deletions
+73 -44
View File
@@ -48,6 +48,17 @@ 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.
# ─────────────────────────────────────────────────────────────────────────────
steps:
@@ -163,6 +174,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,16 +187,26 @@ 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
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
@@ -221,10 +247,38 @@ steps:
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'gamma_auth_token' '$${GAMMA_AUTH_TOKEN}'";;
git)
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'git_db_password' '$${GIT_DB_PASSWORD}'
create_or_update_secret 'git_runner_token' '$${GIT_RUNNER_TOKEN}'
create_or_update_secret 'git_mcp_access_token' '$${GIT_MCP_ACCESS_TOKEN}'";;
# PATTERN B, DELIBERATE (see decision notes) — git hosts the source
# of truth for every other stack's compose files, so it must be
# restorable from a flat git.yaml + git.env backup alone, with zero
# 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 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
# seeds the test file from the real git.env (carries over all
# non-secret lines untouched); every push after that only refreshes
# the 3 secret lines below. Also strips the legacy GITEA_ACCESS_TOKEN
# key name so the test file converges on the git.env.example-
# 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
grep -vE '^(GITEA__database__PASSWD|GITEA_RUNNER_REGISTRATION_TOKEN|GITEA_MCP_ACCESS_TOKEN|GITEA_ACCESS_TOKEN)=' \$FILE > \$TMP 2>/dev/null || touch \$TMP
{ cat \$TMP
printf 'GITEA__database__PASSWD=%s\n' '$${GIT_DB_PASSWORD}'
printf 'GITEA_RUNNER_REGISTRATION_TOKEN=%s\n' '$${GIT_RUNNER_TOKEN}'
printf 'GITEA_MCP_ACCESS_TOKEN=%s\n' '$${GIT_MCP_ACCESS_TOKEN}'
} > \$FILE
rm -f \$TMP
echo ' [OK] git.env.pipelinetest updated - real git.env untouched'";;
homeassistant)
ssh -o StrictHostKeyChecking=no root@$${SWARM_MANAGER_IP} "source /tmp/cs.sh
create_or_update_secret 'homeassistant_frigate_rtsp_password' '$${HOMEASSISTANT_FRIGATE_RTSP_PASSWORD}'
@@ -261,40 +315,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.
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'";;
# 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}'
@@ -352,7 +381,7 @@ steps:
# Bootstrap-tier guard: file synced to host, deploy is MANUAL
case "$STACK" in
traefik|woodpecker|postgresql|secrets)
traefik|woodpecker|postgresql|secrets|git)
echo " [BOOTSTRAP] $STACK: file synced. Deploy is MANUAL."
echo " Run: bash /volume1/docker/compose-files/deploy/stack-deploy.sh $STACK"
continue ;;
+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}
+34 -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: ghcr.io/open-webui/mcpo:main
image: git.bryanmail.net/homelab/flowagent-mcpo:c5b22618404a
command:
- --config
- /app/config/config.json
@@ -141,6 +141,13 @@ services:
- /volume1/docker/mcpo/data:/mcpo_data
- /volume1/docker/cronicle/ssh_keys:/app/ssh_keys:ro
- /volume1/docker/mcpo/uv-cache:/app/uv-cache
secrets:
- source: flowagent_azure_client_id
target: flowagent_azure_client_id
- source: flowagent_azure_tenant_id
target: flowagent_azure_tenant_id
- source: flowagent_azure_client_secret
target: flowagent_azure_client_secret
networks:
- traefik_backend
deploy:
@@ -173,6 +180,13 @@ services:
- traefik.http.middlewares.n8n.headers.STSPreload=true
- traefik.http.routers.n8n.middlewares=forwardAuth-authentik@file, crowdsec@file
- traefik.swarm.network=traefik_backend
secrets:
flowagent_azure_client_id:
external: true
flowagent_azure_tenant_id:
external: true
flowagent_azure_client_secret:
external: true
networks:
traefik_backend:
external: true
+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()
+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