From 0ca9783f61c8813f1f0008f8d77465e5bc332151 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 12 Jul 2026 01:42:37 +0200 Subject: [PATCH] feat(deploy): add optional local pgvector profile --- .superpowers/sdd/pgvector-task-3-report.md | 55 ++++++++++ compose.yaml | 72 ++++++++++++- deploy/env.example | 12 +++ deploy/vector/init/00-bootstrap.sh | 36 +++++++ deploy/workspaces/local-vector.yaml | 48 +++++++++ scripts/local-vector-smoke.sh | 112 +++++++++++++++++++++ 6 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 .superpowers/sdd/pgvector-task-3-report.md create mode 100755 deploy/vector/init/00-bootstrap.sh create mode 100644 deploy/workspaces/local-vector.yaml create mode 100755 scripts/local-vector-smoke.sh diff --git a/.superpowers/sdd/pgvector-task-3-report.md b/.superpowers/sdd/pgvector-task-3-report.md new file mode 100644 index 00000000..d92088c3 --- /dev/null +++ b/.superpowers/sdd/pgvector-task-3-report.md @@ -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. diff --git a/compose.yaml b/compose.yaml index 32c0edae..2f9e424b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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} diff --git a/deploy/env.example b/deploy/env.example index 8417232f..f27d4563 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -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= diff --git a/deploy/vector/init/00-bootstrap.sh b/deploy/vector/init/00-bootstrap.sh new file mode 100755 index 00000000..44f1fb33 --- /dev/null +++ b/deploy/vector/init/00-bootstrap.sh @@ -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 diff --git a/deploy/workspaces/local-vector.yaml b/deploy/workspaces/local-vector.yaml new file mode 100644 index 00000000..3968ed21 --- /dev/null +++ b/deploy/workspaces/local-vector.yaml @@ -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 diff --git a/scripts/local-vector-smoke.sh b/scripts/local-vector-smoke.sh new file mode 100755 index 00000000..ca334542 --- /dev/null +++ b/scripts/local-vector-smoke.sh @@ -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."