From 5c4836564caf0e536181eb92830faa3c606a77ec Mon Sep 17 00:00:00 2001 From: Chewbaccalakis Date: Wed, 23 Sep 2026 21:19:04 +0000 Subject: [PATCH] openwebui setup --- butane/ai.yaml.tftpl | 296 +++++++++++++++++++++++++++++++------------ secrets.tmpl | 9 ++ 2 files changed, 224 insertions(+), 81 deletions(-) diff --git a/butane/ai.yaml.tftpl b/butane/ai.yaml.tftpl index e6df71f..042c04b 100644 --- a/butane/ai.yaml.tftpl +++ b/butane/ai.yaml.tftpl @@ -54,89 +54,173 @@ systemd: RestartSec=5s [Install] WantedBy=multi-user.target - # ── Open WebUI (disabled) ────────────────────────────────────────────── - # Commented out until it is ready to launch. To re-enable: - # 1. strip the leading "# " from every line of the block below (this - # restores the original indentation exactly) - # 2. un-double the dollar signs in each interpolation: $$ back to $ - # 3. re-add the seven OPENWEBUI_* keys to secrets.tmpl (listed below; - # they are removed while this is off so the generated JSON, which - # data.external parses, stays valid) + # Open WebUI, with Qdrant alongside it for document embeddings. # - # OPENWEBUI_DATABASE_URL -> openwebui_database_url - # OPENWEBUI_SECRET_KEY -> openwebui_secret_key - # OPENWEBUI_S3_ACCESS_KEY -> openwebui_s3_access_key - # OPENWEBUI_S3_SECRET_KEY -> openwebui_s3_secret_key - # OPENWEBUI_OAUTH_CLIENT_ID -> openwebui_oauth_client_id - # OPENWEBUI_OAUTH_CLIENT_SECRET -> openwebui_oauth_client_secret - # OPENWEBUI_OIDC_PROVIDER_URL -> openwebui_oidc_provider_url + # Prerequisites, all of which must exist before the VM is rebuilt: + # - Postgres database (plain; no pgvector extension needed -- embeddings + # go to Qdrant, and Postgres only holds users, chats and settings) + # - openwebui bucket in Garage, plus a key with r/w on it + # - an embedding model in LiteLLM matching RAG_EMBEDDING_MODEL below + # - an OIDC client at the provider, with the callback URI registered as + # https://llm.atlashorizon.net/oauth/oidc/callback + # - llm.atlashorizon.net terminating TLS and proxying to this host's + # :3000 (not managed here -- see WEBUI_URL below) + # - the OPENWEBUI_* secrets in Infisical (see secrets.tmpl) # - # The doubling in step 2 is not cosmetic: templatefile() substitutes on - # raw text before anything parses it as YAML, so an interpolation left - # intact here would still be resolved -- and would still fail the plan on - # a missing key -- even though it sits inside a comment. -# # Open WebUI. -# # -# # Prerequisites, all of which must exist before the VM is rebuilt: -# # - Postgres database, with "CREATE EXTENSION vector;" run inside it -# # - openwebui bucket in Garage, plus a key with r/w on it -# # - an embedding model in LiteLLM matching RAG_EMBEDDING_MODEL below -# # - an OIDC client at the provider, with the callback URI registered as -# # https://llm.atlashorizon.net/oauth/oidc/callback -# # - llm.atlashorizon.net terminating TLS and proxying to this host's -# # :3000 (not managed here -- see WEBUI_URL below) -# # - the OPENWEBUI_* secrets in Infisical (see secrets.tmpl) -# - name: open-webui.service -# enabled: true -# contents: | -# [Unit] -# Description=Open WebUI -# After=docker.service litellm.service -# Requires=docker.service -# [Service] -# TimeoutStartSec=0 -# # Large image, so it is pulled up front: a slow first pull is then not -# # mistaken for a hung unit. -# ExecStartPre=/usr/bin/docker pull ghcr.io/open-webui/open-webui:v0.11.4 -# ExecStartPre=-/usr/bin/docker rm --force open-webui -# # No volume is mounted. Every piece of durable state lives off-box -- -# # Postgres for app data, pgvector for embeddings, Garage for uploads -- -# # so /app/backend/data holds nothing worth keeping and the container -# # (and the whole VM) can be rebuilt at will. What stays local is cache -# # only, re-fetched on the next start. -# # -# # gitea-mcp already owns host :8080, so the UI is published on :3000. -# ExecStart=/usr/bin/docker run --name open-webui \ -# -e DATABASE_URL=$${openwebui_database_url} \ -# -e VECTOR_DB=pgvector \ -# -e PGVECTOR_DB_URL=$${openwebui_database_url} \ -# -e STORAGE_PROVIDER=s3 \ -# -e S3_ENDPOINT_URL=http://s3.lan.oswaldtheotter.net:30188 \ -# -e S3_REGION_NAME=garage \ -# -e S3_BUCKET_NAME=openwebui \ -# -e S3_ACCESS_KEY_ID=$${openwebui_s3_access_key} \ -# -e S3_SECRET_ACCESS_KEY=$${openwebui_s3_secret_key} \ -# -e WEBUI_SECRET_KEY=$${openwebui_secret_key} \ -# -e ENABLE_OLLAMA_API=false \ -# -e OPENAI_API_BASE_URL=http://192.168.7.61:4000/v1 \ -# -e OPENAI_API_KEY=$${litellm_master_key} \ -# -e RAG_EMBEDDING_ENGINE=openai \ -# -e RAG_OPENAI_API_BASE_URL=http://192.168.7.61:4000/v1 \ -# -e RAG_OPENAI_API_KEY=$${litellm_master_key} \ -# -e RAG_EMBEDDING_MODEL=text-embedding-3-small \ -# -e OAUTH_CLIENT_ID=$${openwebui_oauth_client_id} \ -# -e OAUTH_CLIENT_SECRET=$${openwebui_oauth_client_secret} \ -# -e OPENID_PROVIDER_URL=$${openwebui_oidc_provider_url} \ -# -e OAUTH_PROVIDER_NAME=SSO \ -# -e ENABLE_OAUTH_SIGNUP=true \ -# -e WEBUI_URL=https://llm.atlashorizon.net \ -# -p 3000:8080 \ -# ghcr.io/open-webui/open-webui:v0.11.4 -# ExecStop=/usr/bin/docker stop open-webui -# Restart=always -# RestartSec=5s -# [Install] -# WantedBy=multi-user.target + # After a taint: the vector store is empty and Open WebUI does not say so + # -- knowledge bases still appear, retrieval just returns nothing. Re-embed + # with POST /api/v1/knowledge/reindex (admin). Note that this covers + # knowledge bases only: files attached directly to a single chat are not + # re-indexed, so anything worth keeping belongs in a knowledge base. + - name: qdrant.service + enabled: true + contents: | + [Unit] + Description=Qdrant vector database + After=docker.service + Requires=docker.service + [Service] + TimeoutStartSec=0 + # Both containers sit on a private docker network and Qdrant publishes + # no ports: it ships with no authentication, and nothing outside this + # host needs to reach it. Creation is idempotent, hence the "-". + ExecStartPre=-/usr/bin/docker network create openwebui + ExecStartPre=/usr/bin/docker pull qdrant/qdrant:v1.19.1 + ExecStartPre=-/usr/bin/docker rm --force qdrant + # Vectors live on the VM disk, so a taint destroys them. That is + # deliberate -- they are derived data, rebuildable from the source + # files in Garage (see the reindex note above). The bind mount is what + # keeps them across container restarts and image pulls, which the + # docker rm above would otherwise wipe every time. + ExecStart=/usr/bin/docker run --name qdrant \ + --network openwebui \ + -v /var/lib/qdrant:/qdrant/storage \ + qdrant/qdrant:v1.19.1 + ExecStop=/usr/bin/docker stop qdrant + Restart=always + RestartSec=5s + [Install] + WantedBy=multi-user.target + # Open Terminal: the sandbox Open WebUI's models get a shell in, for + # running code, installing packages and working on files. + # + # Anything a model does lands inside this container, not on the host: no + # docker socket, no host paths, no published port. It is reachable only + # over the private "openwebui" network, so the API key below is defence in + # depth rather than the only thing standing in the way. + # + # Note that it does have outbound network access -- it needs it to install + # packages -- so a model can reach the rest of the LAN from in here. That + # is the part worth thinking about before pointing an untrusted model at + # it; docker's --internal would close it off, but would also break package + # installation, which is most of the point. + - name: open-terminal.service + enabled: true + contents: | + [Unit] + Description=Open Terminal sandbox + After=docker.service + Requires=docker.service + [Service] + TimeoutStartSec=0 + ExecStartPre=-/usr/bin/docker network create openwebui + ExecStartPre=/usr/bin/docker pull ghcr.io/open-webui/open-terminal:0.14.0 + ExecStartPre=-/usr/bin/docker rm --force open-terminal + # A named volume, not a bind mount: /home/user belongs to the image's + # unprivileged user, and a named volume inherits that ownership from + # the image where a root-owned bind mount would not. It lives under + # /var/lib/docker on the VM disk, so a taint discards the workspace -- + # the right lifetime for scratch space. + ExecStart=/usr/bin/docker run --name open-terminal \ + --network openwebui \ + -v open-terminal:/home/user \ + -e OPEN_TERMINAL_API_KEY=${openwebui_terminal_api_key} \ + ghcr.io/open-webui/open-terminal:0.14.0 + ExecStop=/usr/bin/docker stop open-terminal + Restart=always + RestartSec=5s + [Install] + WantedBy=multi-user.target + - name: open-webui.service + enabled: true + contents: | + [Unit] + Description=Open WebUI + # After= orders the start only, it does not wait for Qdrant to be + # serving. Restart=always covers the gap if Qdrant is still coming up. + After=docker.service litellm.service qdrant.service open-terminal.service + Requires=docker.service + [Service] + TimeoutStartSec=0 + # Large image, so it is pulled up front: a slow first pull is then not + # mistaken for a hung unit. + ExecStartPre=/usr/bin/docker pull ghcr.io/open-webui/open-webui:v0.11.4 + ExecStartPre=-/usr/bin/docker network create openwebui + ExecStartPre=-/usr/bin/docker rm --force open-webui + # No volume is mounted. Every piece of durable state lives off-box -- + # Postgres for app data, Garage for uploads -- so /app/backend/data + # holds nothing worth keeping and the container can be recreated at + # will. Qdrant is the one exception, and it is expendable by design. + # + # gitea-mcp already owns host :8080, so the UI is published on :3000. + ExecStart=/usr/bin/docker run --name open-webui \ + --network openwebui \ + -e DATABASE_URL=${openwebui_database_url} \ + -e VECTOR_DB=qdrant \ + -e QDRANT_URI=http://qdrant:6333 \ + -e QDRANT_ON_DISK=true \ + -e STORAGE_PROVIDER=s3 \ + -e S3_ENDPOINT_URL=http://s3.lan.oswaldtheotter.net:30188 \ + -e S3_REGION_NAME=garage \ + -e S3_BUCKET_NAME=openwebui \ + -e S3_ACCESS_KEY_ID=${openwebui_s3_access_key} \ + -e S3_SECRET_ACCESS_KEY=${openwebui_s3_secret_key} \ + -e WEBUI_SECRET_KEY=${openwebui_secret_key} \ + -e ENABLE_OLLAMA_API=false \ + -e OPENAI_API_BASE_URL=http://192.168.7.61:4000/v1 \ + -e OPENAI_API_KEY=${litellm_master_key} \ + -e RAG_EMBEDDING_ENGINE=openai \ + -e RAG_OPENAI_API_BASE_URL=http://192.168.7.61:4000/v1 \ + -e RAG_OPENAI_API_KEY=${litellm_master_key} \ + -e RAG_EMBEDDING_MODEL=text-embedding-3-small \ + -e OAUTH_CLIENT_ID=${openwebui_oauth_client_id} \ + -e OAUTH_CLIENT_SECRET=${openwebui_oauth_client_secret} \ + -e OPENID_PROVIDER_URL=${openwebui_oidc_provider_url} \ + -e OAUTH_PROVIDER_NAME=AtlasHorizon \ + -e ENABLE_OAUTH_SIGNUP=true \ + -e OAUTH_UPDATE_PICTURE_ON_LOGIN=true \ + -e ENABLE_OAUTH_GROUP_MANAGEMENT=true \ + -e OAUTH_PICTURE_CLAIM=avatar \ + -e "OAUTH_SCOPES=openid email profile groups avatar" \ + -e OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true \ + -e WEBUI_URL=https://llm.atlashorizon.net \ + -e 'TERMINAL_SERVER_CONNECTIONS=[{"id":"local","name":"Open Terminal","url":"http://open-terminal:8000","key":"${openwebui_terminal_api_key}","auth_type":"bearer","enabled":true}]' \ + -p 3000:8080 \ + ghcr.io/open-webui/open-webui:v0.11.4 + ExecStop=/usr/bin/docker stop open-webui + Restart=always + RestartSec=5s + [Install] + WantedBy=multi-user.target + # Rebuilds the vector store after a taint. Runs on every boot, but only + # acts when Qdrant holds no Open WebUI collections -- that is, when the + # local volume went with the VM. + # + # The admin API key it authenticates with lives in Postgres, so it + # survives the rebuild that wipes Qdrant. That is what makes this work. + - name: openwebui-reindex.service + enabled: true + contents: | + [Unit] + Description=Rebuild Open WebUI vectors when Qdrant is empty + After=open-webui.service qdrant.service + Requires=open-webui.service qdrant.service + [Service] + Type=oneshot + TimeoutStartSec=30min + ExecStart=/opt/bin/openwebui-reindex.sh + [Install] + WantedBy=multi-user.target - name: dozzle-agent.service enabled: true contents: | @@ -158,6 +242,12 @@ systemd: WantedBy=multi-user.target storage: + directories: + # Qdrant's vector store. On the VM disk on purpose: a taint is meant to + # discard it. The qdrant image runs as root (ARG USER_ID=0), so a + # root-owned 0700 directory is what it expects. + - path: /var/lib/qdrant + mode: 0700 files: # LiteLLM proxy settings. Models stay in the database # (STORE_MODEL_IN_DB), so this file carries general_settings only -- @@ -175,6 +265,50 @@ storage: # honoured only when the direct peer is the connector, so a crafted # header from anywhere else cannot forge a client IP. Widen it if the # tunnel ever moves off that subnet, or the header is ignored again. + # Companion to openwebui-reindex.service above. + # + # Note on quoting: templatefile() substitutes on raw text, so shell + # variables are written as $VAR without braces. A braced shell variable + # anywhere in this script would be read as a template reference and fail + # the plan; only the interpolation below is meant to be substituted. + - path: /opt/bin/openwebui-reindex.sh + mode: 0700 + contents: + inline: | + #!/usr/bin/bash + set -euo pipefail + OWUI=http://127.0.0.1:3000 + OWUI_KEY='${openwebui_admin_api_key}' + + # Qdrant publishes no ports -- it is reachable only on the private + # "openwebui" docker network -- so it is probed from inside the + # open-webui container, which is on that network and ships curl. + qdrant_curl() { + docker exec open-webui curl -fsS "$@" + } + + # Wait up to 10 minutes for both services to answer. + ready=0 + for i in $(seq 1 120); do + if curl -fsS "$OWUI/health" >/dev/null 2>&1 \ + && qdrant_curl http://qdrant:6333/readyz >/dev/null 2>&1; then + ready=1; break + fi + sleep 5 + done + [ "$ready" = 1 ] || { echo "services never became ready"; exit 1; } + + # Collections are named "_", and the + # prefix defaults to open-webui. Any hit means the volume survived. + if qdrant_curl http://qdrant:6333/collections \ + | grep -q '"name":"open-webui'; then + echo "Qdrant already populated; skipping reindex" + exit 0 + fi + + echo "Qdrant empty; reindexing knowledge bases" + curl -fsS -X POST -H "Authorization: Bearer $OWUI_KEY" \ + "$OWUI/api/v1/knowledge/reindex" - path: /etc/litellm/config.yaml mode: 0644 contents: diff --git a/secrets.tmpl b/secrets.tmpl index dc4c417..b6a6b00 100644 --- a/secrets.tmpl +++ b/secrets.tmpl @@ -15,6 +15,15 @@ {{- if eq $secret.Key "LITELLM_MASTER_KEY" }}"litellm_master_key": "{{$secret.Value}}",{{ end }} {{- if eq $secret.Key "LITELLM_SALT_KEY" }}"litellm_salt_key": "{{$secret.Value}}",{{ end }} {{- if eq $secret.Key "HEADSCALE_AUTHKEY" }}"headscale_authkey": "{{$secret.Value}}",{{ end }} + {{- if eq $secret.Key "OPENWEBUI_DATABASE_URL" }}"openwebui_database_url": "{{$secret.Value}}",{{ end }} + {{- if eq $secret.Key "OPENWEBUI_SECRET_KEY" }}"openwebui_secret_key": "{{$secret.Value}}",{{ end }} + {{- if eq $secret.Key "OPENWEBUI_S3_ACCESS_KEY" }}"openwebui_s3_access_key": "{{$secret.Value}}",{{ end }} + {{- if eq $secret.Key "OPENWEBUI_S3_SECRET_KEY" }}"openwebui_s3_secret_key": "{{$secret.Value}}",{{ end }} + {{- if eq $secret.Key "OPENWEBUI_OAUTH_CLIENT_ID" }}"openwebui_oauth_client_id": "{{$secret.Value}}",{{ end }} + {{- if eq $secret.Key "OPENWEBUI_OAUTH_CLIENT_SECRET" }}"openwebui_oauth_client_secret": "{{$secret.Value}}",{{ end }} + {{- if eq $secret.Key "OPENWEBUI_OIDC_PROVIDER_URL" }}"openwebui_oidc_provider_url": "{{$secret.Value}}",{{ end }} + {{- if eq $secret.Key "OPENWEBUI_TERMINAL_API_KEY" }}"openwebui_terminal_api_key": "{{$secret.Value}}",{{ end }} + {{- if eq $secret.Key "OPENWEBUI_ADMIN_API_KEY" }}"openwebui_admin_api_key": "{{$secret.Value}}",{{ end }} {{- if eq $secret.Key "DOZZLE_OIDC_CLIENT_ID" }}"dozzle_oidc_client_id": "{{$secret.Value}}",{{ end }} {{- if eq $secret.Key "DOZZLE_OIDC_CLIENT_SECRET" }}"dozzle_oidc_client_secret": "{{$secret.Value}}",{{ end }} {{- end }}