feat(deploy): add optional local pgvector profile
This commit is contained in:
@@ -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
@@ -2,7 +2,8 @@ name: thothii
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
core:
|
core:
|
||||||
profiles: [external]
|
image: thothii-core:local
|
||||||
|
profiles: [external, local-vector]
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/core.Dockerfile
|
dockerfile: docker/core.Dockerfile
|
||||||
@@ -11,9 +12,18 @@ services:
|
|||||||
THOTH_PUBLIC_EXPOSURE: "${THOTH_PUBLIC_EXPOSURE:-false}"
|
THOTH_PUBLIC_EXPOSURE: "${THOTH_PUBLIC_EXPOSURE:-false}"
|
||||||
THT_DATA_ROOT: /data
|
THT_DATA_ROOT: /data
|
||||||
SETTINGS_FILE: /data/settings/settings.json
|
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:
|
volumes:
|
||||||
- thoth_data:/data
|
- thoth_data:/data
|
||||||
- ./deploy/workspaces:/app/harness/workspaces:ro
|
- ./deploy/workspaces:/app/harness/workspaces:ro
|
||||||
|
depends_on:
|
||||||
|
vector-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
required: false
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [CMD, curl, --fail, --silent, http://127.0.0.1:8787/health]
|
test: [CMD, curl, --fail, --silent, http://127.0.0.1:8787/health]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -23,7 +33,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
profiles: [external]
|
profiles: [external, local-vector]
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/frontend.Dockerfile
|
dockerfile: docker/frontend.Dockerfile
|
||||||
@@ -42,5 +52,63 @@ services:
|
|||||||
start_period: 5s
|
start_period: 5s
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
thoth_data:
|
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}
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ THT_VEC_REST_URL=
|
|||||||
THT_VEC_API_KEY=
|
THT_VEC_API_KEY=
|
||||||
THT_VEC_WRITE_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
|
# External embeddings service
|
||||||
THT_OLLAMA_URL=
|
THT_OLLAMA_URL=
|
||||||
|
|
||||||
|
|||||||
Executable
+36
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+112
@@ -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."
|
||||||
Reference in New Issue
Block a user