feat(deploy): add optional local pgvector profile

This commit is contained in:
2026-07-12 01:42:37 +02:00
parent 3588a7749b
commit 0ca9783f61
6 changed files with 333 additions and 2 deletions
@@ -0,0 +1,55 @@
# Task 3 report — optional local pgvector profile
## Status
Implemented and verified the `local-vector` Compose profile.
- `vector-db` uses pgvector 0.8.5 on PostgreSQL 16, pinned to the official multi-arch
manifest digest.
- `vector_data` is a project-scoped named volume and is not shared with application data.
- database readiness gates the packaged one-shot `vector-migrate` job; core declares the
migration completion dependency while remaining usable in the pre-existing external profile.
- bootstrap, migrator, reader, and writer identities are distinct. Bootstrap and migration
credentials are supplied as Compose secrets; the application receives only reader/writer
credentials.
- `deploy/workspaces/local-vector.yaml` selects `pgvector_direct` with separate reader and
writer connections.
- the base loopback port binding, `AUTH_MODE=none`, and `THOTH_PUBLIC_EXPOSURE=false` defaults
are unchanged.
## Red/green evidence
The initial Compose contract did not list `vector-db`, as required by the brief. The first real
smoke then failed migration 002 because bootstrap installed the vector extension in `public`.
The bootstrap was corrected to create the `vectors` schema under the migration owner and install
the extension there. A clean-volume rerun passed.
## Verification
- `./scripts/local-vector-smoke.sh`: PASS
- isolated generated Compose project and credentials
- clean migration plus idempotent status rerun
- reader/writer privilege health
- one-record upsert and similarity search
- restart of both `core` and `vector-db`
- persisted search result after restart
- project-only volume cleanup
- `./scripts/test-container-deployment.sh`: PASS
- `./scripts/test-backend-url-policy.sh`: PASS
- `docker compose --profile local-vector config --quiet`: PASS
- harness: 477 passed, 5 deselected
- backend: 84 passed; TypeScript typecheck PASS
- frontend: 226 passed; TypeScript typecheck PASS
- `git diff --check`: PASS
## Self-review / concerns
- Compose cannot make a dependency required only under one profile. The core dependency uses
`required: false` so the established `external` profile does not activate local infrastructure;
under `local-vector`, `compose up --wait` still fails if `vector-migrate` exits nonzero, and the
smoke verifies that successful migration precedes the healthy stack.
- Reader/writer passwords are injected into core environment variables because Compose service
attributes cannot be conditional by profile. Bootstrap and migrator credentials remain
file-backed secrets and are never exposed to core.
- The smoke intentionally refuses the operator project name `thothii` and removes only its unique
project namespace and volumes.
+70 -2
View File
@@ -2,7 +2,8 @@ name: thothii
services:
core:
profiles: [external]
image: thothii-core:local
profiles: [external, local-vector]
build:
context: .
dockerfile: docker/core.Dockerfile
@@ -11,9 +12,18 @@ services:
THOTH_PUBLIC_EXPOSURE: "${THOTH_PUBLIC_EXPOSURE:-false}"
THT_DATA_ROOT: /data
SETTINGS_FILE: /data/settings/settings.json
THT_VECTOR_DATABASE: "${THT_VECTOR_DATABASE:-thoth}"
THT_VECTOR_READER_USER: "${THT_VECTOR_READER_USER:-thoth_vector_reader}"
THT_VECTOR_WRITER_USER: "${THT_VECTOR_WRITER_USER:-thoth_vector_writer}"
THT_VECTOR_READER_PASSWORD: "${THT_VECTOR_READER_PASSWORD:-}"
THT_VECTOR_WRITER_PASSWORD: "${THT_VECTOR_WRITER_PASSWORD:-}"
volumes:
- thoth_data:/data
- ./deploy/workspaces:/app/harness/workspaces:ro
depends_on:
vector-migrate:
condition: service_completed_successfully
required: false
healthcheck:
test: [CMD, curl, --fail, --silent, http://127.0.0.1:8787/health]
interval: 5s
@@ -23,7 +33,7 @@ services:
restart: unless-stopped
frontend:
profiles: [external]
profiles: [external, local-vector]
build:
context: .
dockerfile: docker/frontend.Dockerfile
@@ -42,5 +52,63 @@ services:
start_period: 5s
restart: unless-stopped
vector-db:
image: pgvector/pgvector:0.8.5-pg16@sha256:1d533553fefe4f12e5d80c7b80622ba0c382abb5758856f52983d8789179f0fb
profiles: [local-vector]
environment:
POSTGRES_DB: "${THT_VECTOR_DATABASE:-thoth}"
POSTGRES_USER: "${THT_VECTOR_BOOTSTRAP_USER:-postgres}"
POSTGRES_PASSWORD_FILE: /run/secrets/vector_bootstrap_password
THT_VECTOR_MIGRATOR_USER: "${THT_VECTOR_MIGRATOR_USER:-thoth_vector_migrator}"
THT_VECTOR_READER_USER: "${THT_VECTOR_READER_USER:-thoth_vector_reader}"
THT_VECTOR_WRITER_USER: "${THT_VECTOR_WRITER_USER:-thoth_vector_writer}"
secrets:
- vector_bootstrap_password
- vector_migrator_password
- vector_reader_password
- vector_writer_password
volumes:
- vector_data:/var/lib/postgresql/data
- ./deploy/vector/init/00-bootstrap.sh:/docker-entrypoint-initdb.d/00-bootstrap.sh:ro
healthcheck:
test: [CMD-SHELL, "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
restart: unless-stopped
vector-migrate:
image: thothii-core:local
profiles: [local-vector]
build:
context: .
dockerfile: docker/core.Dockerfile
entrypoint: [sh, -ec]
command:
- |
password=$$(cat /run/secrets/vector_migrator_password)
encoded=$$(python -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$$password")
exec /opt/venv/bin/tht vector migrate \
--database-url "postgresql+psycopg2://${THT_VECTOR_MIGRATOR_USER:-thoth_vector_migrator}:$$encoded@vector-db:5432/${THT_VECTOR_DATABASE:-thoth}" \
--json
secrets:
- vector_migrator_password
depends_on:
vector-db:
condition: service_healthy
restart: "no"
volumes:
thoth_data:
vector_data:
secrets:
vector_bootstrap_password:
file: ${THT_VECTOR_BOOTSTRAP_PASSWORD_SECRET_FILE:-deploy/secrets/vector_bootstrap_password}
vector_migrator_password:
file: ${THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE:-deploy/secrets/vector_migrator_password}
vector_reader_password:
file: ${THT_VECTOR_READER_PASSWORD_SECRET_FILE:-deploy/secrets/vector_reader_password}
vector_writer_password:
file: ${THT_VECTOR_WRITER_PASSWORD_SECRET_FILE:-deploy/secrets/vector_writer_password}
+12
View File
@@ -18,6 +18,18 @@ THT_VEC_REST_URL=
THT_VEC_API_KEY=
THT_VEC_WRITE_API_KEY=
# Optional local-vector profile. Keep these secret files outside Git and readable by Docker.
THT_VECTOR_DATABASE=thoth
THT_VECTOR_MIGRATOR_USER=thoth_vector_migrator
THT_VECTOR_READER_USER=thoth_vector_reader
THT_VECTOR_WRITER_USER=thoth_vector_writer
THT_VECTOR_READER_PASSWORD=
THT_VECTOR_WRITER_PASSWORD=
THT_VECTOR_BOOTSTRAP_PASSWORD_SECRET_FILE=/absolute/path/to/vector_bootstrap_password
THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE=/absolute/path/to/vector_migrator_password
THT_VECTOR_READER_PASSWORD_SECRET_FILE=/absolute/path/to/vector_reader_password
THT_VECTOR_WRITER_PASSWORD_SECRET_FILE=/absolute/path/to/vector_writer_password
# External embeddings service
THT_OLLAMA_URL=
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
set -eu
read_secret() {
value=$(cat "/run/secrets/$1")
if [ -z "$value" ] || printf '%s' "$value" | grep -q '[[:space:]]'; then
echo "$1 must be non-empty and contain no whitespace" >&2
exit 2
fi
printf '%s' "$value"
}
migrator_password=$(read_secret vector_migrator_password)
reader_password=$(read_secret vector_reader_password)
writer_password=$(read_secret vector_writer_password)
psql --set=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
--set=migrator_user="$THT_VECTOR_MIGRATOR_USER" \
--set=migrator_password="$migrator_password" \
--set=reader_user="$THT_VECTOR_READER_USER" \
--set=reader_password="$reader_password" \
--set=writer_user="$THT_VECTOR_WRITER_USER" \
--set=writer_password="$writer_password" <<'SQL'
CREATE ROLE vector_reader NOLOGIN;
CREATE ROLE vector_writer NOLOGIN;
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L CREATEROLE', :'migrator_user', :'migrator_password') \gexec
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'reader_user', :'reader_password') \gexec
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'writer_user', :'writer_password') \gexec
SELECT format('GRANT vector_reader TO %I', :'reader_user') \gexec
SELECT format('GRANT vector_writer TO %I', :'writer_user') \gexec
SELECT format('ALTER DATABASE %I OWNER TO %I', current_database(), :'migrator_user') \gexec
SELECT format('CREATE SCHEMA vectors AUTHORIZATION %I', :'migrator_user') \gexec
REVOKE ALL ON SCHEMA vectors FROM PUBLIC;
CREATE EXTENSION vector WITH SCHEMA vectors;
SQL
+48
View File
@@ -0,0 +1,48 @@
language: en
dwh:
type: thoth_rest
database:
database: ${THT_DB_NAME}
schema: datawarehouse
endpoint:
base_url: ${THT_DWH_REST_URL}
api_key: ${THT_DWH_API_KEY}
vectors:
type: pgvector_direct
reader:
host: vector-db
port: 5432
database: ${THT_VECTOR_DATABASE}
schema: vectors
user: ${THT_VECTOR_READER_USER}
password: ${THT_VECTOR_READER_PASSWORD}
writer:
host: vector-db
port: 5432
database: ${THT_VECTOR_DATABASE}
schema: vectors
user: ${THT_VECTOR_WRITER_USER}
password: ${THT_VECTOR_WRITER_PASSWORD}
roots:
artifacts: artifacts
indexes: indexes
sessions: sessions
evidence:
source_root: ${THT_DOCS_ROOT}
evidence_dir: evidence
embeddings:
base_url: ${THT_OLLAMA_URL}
model: nomic-embed-text-v2-moe
dim: 768
batch_size: 32
execution:
allow: [cte_test, explain, preview, aggregate, export]
max_preview_rows: 10
max_export_rows: 100000
statement_timeout_ms: 30000
+112
View File
@@ -0,0 +1,112 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
smoke_project=${SMOKE_PROJECT:-"thothii-vector-smoke-$(date +%s)-$$"}
keep_resources=${KEEP_SMOKE_RESOURCES:-0}
secret_dir=$(mktemp -d)
marker="local-vector-$smoke_project"
case "$smoke_project" in
thothii)
echo "SMOKE_PROJECT=thothii is reserved for the operator stack" >&2
exit 2
;;
""|*[!a-z0-9_-]*|[!a-z0-9]*)
echo "invalid SMOKE_PROJECT: use lowercase letters, digits, hyphens, or underscores" >&2
exit 2
;;
esac
for secret in bootstrap migrator reader writer; do
password="smoke-${secret}-${smoke_project}"
printf '%s' "$password" >"$secret_dir/$secret"
chmod 0600 "$secret_dir/$secret"
done
export THT_VECTOR_BOOTSTRAP_PASSWORD_SECRET_FILE="$secret_dir/bootstrap"
export THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE="$secret_dir/migrator"
export THT_VECTOR_READER_PASSWORD_SECRET_FILE="$secret_dir/reader"
export THT_VECTOR_WRITER_PASSWORD_SECRET_FILE="$secret_dir/writer"
export THT_VECTOR_READER_PASSWORD="smoke-reader-${smoke_project}"
export THT_VECTOR_WRITER_PASSWORD="smoke-writer-${smoke_project}"
compose() {
docker compose --project-name "$smoke_project" --profile local-vector "$@"
}
cleanup() {
if [ "$keep_resources" = "1" ]; then
echo "Keeping smoke resources for project $smoke_project (KEEP_SMOKE_RESOURCES=1)." >&2
else
compose down --volumes >/dev/null 2>&1 || true
fi
rm -rf "$secret_dir"
}
trap cleanup EXIT HUP INT TERM
probe_vector() {
compose exec -T core /opt/venv/bin/python - "$marker" <<'PY'
import hashlib
import os
import sys
from tht.adapters.vector.pgvector import PgVectorStore
from tht.config import DatabaseConfig
from tht.ports.vector import VectorWriteRecord
from tht.vectorstore.records import VectorRecord
marker = sys.argv[1]
database = "thoth"
host = "vector-db"
def credential(role: str) -> DatabaseConfig:
return DatabaseConfig(
host=host,
port=5432,
database=database,
schema="vectors",
user=f"thoth_vector_{role}",
password=os.environ[f"THT_VECTOR_{role.upper()}_PASSWORD"],
)
store = PgVectorStore(credential("reader"), credential("writer"), expected_dimension=768)
health = store.health()
assert health.ok, health
assert health.read_reachable is True and health.write_reachable is True, health
record = VectorRecord(
id=marker,
kind="memory",
ref=marker,
title="Local vector persistence smoke",
content=marker,
metadata={"smoke": True},
)
embedding = [1.0] + [0.0] * 767
store.upsert(
"memory",
[VectorWriteRecord(record, embedding, hashlib.sha256(marker.encode()).hexdigest())],
)
hits = store.search(["memory"], embedding, limit=1, kinds=["memory"])
assert hits and hits[0].id == marker, hits
print(f"role health, upsert, and search passed for {marker}")
PY
}
compose config --quiet
services=$(compose config --services)
printf '%s\n' "$services" | grep -qx vector-db
printf '%s\n' "$services" | grep -qx vector-migrate
compose up --build --wait vector-migrate core
migration_status=$(compose run --rm --no-deps vector-migrate)
printf '%s\n' "$migration_status" | grep -q '"pending": \[\]'
probe_vector
compose restart vector-db core
compose up --wait vector-db core
probe_vector
echo "Local pgvector migration, least-privilege roles, search, and restart persistence passed."