fix(vector): harden packaged migrations

This commit is contained in:
2026-07-12 01:32:33 +02:00
parent c0d50e9b08
commit 3588a7749b
14 changed files with 337 additions and 54 deletions
@@ -50,3 +50,33 @@ No unresolved Task 2 correctness concern found. One deliberate contract choice i
writer `INSERT` and `UPDATE` are table-level because the approved direct adapter health probe uses writer `INSERT` and `UPDATE` are table-level because the approved direct adapter health probe uses
`has_table_privilege` for those authorities. Least privilege is retained by withholding broad `has_table_privilege` for those authorities. Least privilege is retained by withholding broad
`SELECT`, `DELETE`, DDL, ownership, and credentials. `SELECT`, `DELETE`, DDL, ownership, and credentials.
## Review fix wave
The post-implementation review found four production-boundary gaps. They are fixed as follows:
- Migration SQL now ships inside the `tht` wheel (`tht/migrations/vector`) via explicit
setuptools package-data and is discovered through `importlib.resources`, rather than relying on
a source-checkout-relative directory.
- Both status and apply reject ledger versions absent from the installed manifest, including
nonnumeric future version labels. This treats a binary/database downgrade as drift instead of
silently reporting a healthy state.
- Migration files are ordered by parsed integer version; spellings such as `2` and `02` are
rejected as duplicate versions.
- Every migration transaction pins `search_path` locally to `pg_catalog, pg_temp`; catalog calls
and the ledger are schema-qualified. pgvector is installed into the locked `vectors` schema,
tables use `vectors.vector`, and `PgVectorStore` qualifies vector casts and the cosine operator.
A hostile admin default path with a writable shadow schema cannot redirect migration objects.
- The core image build asserts CLI discovery. Image verification now starts an ephemeral pgvector
database, runs the installed image's migration command, and compares pristine apply/status JSON.
Additional verification after the fix wave:
- Focused migration, adapter, hostile-path, and wheel suite: `27 passed`.
- Full harness: `477 passed, 5 deselected`.
- Production core image build: passed, including build-time CLI discovery.
- Core-image apply/status smoke against `pgvector/pgvector:pg16`: passed.
- Changed production and test files: Ruff clean; `git diff --check` clean.
- Full Ruff remains at the same 34 pre-existing unrelated test-file findings documented above.
No dependency changed, so the committed Python requirements lock did not require regeneration.
+2 -1
View File
@@ -40,7 +40,8 @@ COPY docker/python-runtime/requirements.lock /app/docker/python-runtime/requirem
RUN python -m venv /opt/venv \ RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir --require-hashes \ && /opt/venv/bin/pip install --no-cache-dir --require-hashes \
--requirement /app/docker/python-runtime/requirements.lock \ --requirement /app/docker/python-runtime/requirements.lock \
&& /opt/venv/bin/pip install --no-cache-dir --no-deps --no-build-isolation /app/harness && /opt/venv/bin/pip install --no-cache-dir --no-deps --no-build-isolation /app/harness \
&& /opt/venv/bin/tht vector migrate --help >/dev/null
COPY --from=backend-build /src/backend/dist /app/backend/dist COPY --from=backend-build /src/backend/dist /app/backend/dist
COPY --from=backend-build /src/backend/node_modules /app/backend/node_modules COPY --from=backend-build /src/backend/node_modules /app/backend/node_modules
@@ -1 +0,0 @@
CREATE EXTENSION IF NOT EXISTS vector;
+3
View File
@@ -31,6 +31,9 @@ dev = [
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["tht*"] include = ["tht*"]
[tool.setuptools.package-data]
tht = ["migrations/vector/*.sql"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
+2 -2
View File
@@ -45,8 +45,8 @@ def vector_configs():
) )
engine = create_engine(pg.get_connection_url()) engine = create_engine(pg.get_connection_url())
with engine.begin() as connection: with engine.begin() as connection:
connection.exec_driver_sql("CREATE EXTENSION vector")
connection.exec_driver_sql("CREATE SCHEMA vectors") connection.exec_driver_sql("CREATE SCHEMA vectors")
connection.exec_driver_sql("CREATE EXTENSION vector WITH SCHEMA vectors")
for table in ("schema_records", "evidence", "memory"): for table in ("schema_records", "evidence", "memory"):
connection.exec_driver_sql(f""" connection.exec_driver_sql(f"""
CREATE TABLE vectors.{table} ( CREATE TABLE vectors.{table} (
@@ -55,7 +55,7 @@ def vector_configs():
kind text NOT NULL, kind text NOT NULL,
content_hash text NOT NULL, content_hash text NOT NULL,
metadata jsonb NOT NULL, metadata jsonb NOT NULL,
embedding vector(2) NOT NULL, embedding vectors.vector(2) NOT NULL,
indexed_at timestamptz NOT NULL DEFAULT now() indexed_at timestamptz NOT NULL DEFAULT now()
) )
""") """)
+111 -13
View File
@@ -59,9 +59,9 @@ def test_schema_matches_direct_adapter_contract(database_url):
for table in tables: for table in tables:
assert {row.column_name for row in rows if row.table_name == table} == required assert {row.column_name for row in rows if row.table_name == table} == required
assert vector_types == [ assert vector_types == [
("evidence", "vector(768)"), ("evidence", "vectors.vector(768)"),
("memory", "vector(768)"), ("memory", "vectors.vector(768)"),
("schema_records", "vector(768)"), ("schema_records", "vectors.vector(768)"),
] ]
@@ -119,7 +119,7 @@ def test_roles_have_runtime_privileges_only(database_url):
"INSERT INTO vectors.memory " "INSERT INTO vectors.memory "
"(record_key, kind, content_hash, metadata, embedding) " "(record_key, kind, content_hash, metadata, embedding) "
"VALUES ('reader-write', 'memory', 'x', '{}', " "VALUES ('reader-write', 'memory', 'x', '{}', "
"array_fill(0, ARRAY[768])::vector)" "array_fill(0, ARRAY[768])::vectors.vector)"
) )
) )
@@ -128,7 +128,8 @@ def test_roles_have_runtime_privileges_only(database_url):
text( text(
"INSERT INTO vectors.memory " "INSERT INTO vectors.memory "
"(record_key, kind, content_hash, metadata, embedding) " "(record_key, kind, content_hash, metadata, embedding) "
"VALUES ('writer-ok', 'memory', 'x', '{}', array_fill(0, ARRAY[768])::vector)" "VALUES ('writer-ok', 'memory', 'x', '{}', "
"array_fill(0, ARRAY[768])::vectors.vector)"
) )
) )
assert connection.execute( assert connection.execute(
@@ -176,15 +177,112 @@ def test_checksum_drift_is_reported_and_refused(database_url, tmp_path):
migrate(database_url, migrations) migrate(database_url, migrations)
def test_unknown_applied_version_is_downgrade_drift(database_url):
from tht.cli.vector_migrate_cmd import MigrationError, migrate, migration_status
migrate(database_url)
engine = create_engine(database_url)
with engine.begin() as connection:
connection.execute(
text(
"INSERT INTO public.tht_vector_migrations (version, name, checksum) "
"VALUES ('999', 'future', 'future-checksum'), "
"('future_x', 'future_named', 'future-checksum')"
)
)
try:
with pytest.raises(
MigrationError, match="absent from local manifest: 999, future_x"
):
migration_status(database_url)
with pytest.raises(
MigrationError, match="absent from local manifest: 999, future_x"
):
migrate(database_url)
finally:
with engine.begin() as connection:
connection.execute(
text(
"DELETE FROM public.tht_vector_migrations "
"WHERE version IN ('999', 'future_x')"
)
)
engine.dispose()
def test_migration_versions_sort_numerically_and_reject_numeric_duplicates(tmp_path):
from tht.cli.vector_migrate_cmd import MigrationError, _discover
migrations = tmp_path / "ordered"
migrations.mkdir()
(migrations / "10_tenth.sql").write_text("SELECT 10;\n")
(migrations / "2_second.sql").write_text("SELECT 2;\n")
assert [item.version for item in _discover(migrations)] == ["2", "10"]
(migrations / "02_duplicate.sql").write_text("SELECT 2;\n")
with pytest.raises(MigrationError, match="Duplicate migration version: 2"):
_discover(migrations)
def test_hostile_admin_search_path_cannot_shadow_migration_objects(database_url):
from tht.cli.vector_migrate_cmd import migrate
admin = create_engine(database_url, isolation_level="AUTOCOMMIT")
with admin.connect() as connection:
connection.exec_driver_sql("DROP DATABASE IF EXISTS vector_hostile")
connection.exec_driver_sql("CREATE DATABASE vector_hostile")
hostile_url = admin.url.set(database="vector_hostile")
hostile = create_engine(hostile_url)
try:
with hostile.begin() as connection:
connection.exec_driver_sql("CREATE SCHEMA shadow")
connection.exec_driver_sql(
"CREATE TABLE shadow.tht_vector_migrations "
"(version text, checksum text, poisoned boolean DEFAULT true)"
)
connection.exec_driver_sql("ALTER ROLE test SET search_path = shadow, public")
hostile.dispose()
migrate(hostile_url.render_as_string(hide_password=False))
verification = create_engine(hostile_url)
with verification.connect() as connection:
assert connection.execute(
text("SELECT count(*) FROM public.tht_vector_migrations")
).scalar_one() == 3
assert connection.execute(
text("SELECT count(*) FROM shadow.tht_vector_migrations")
).scalar_one() == 0
assert connection.execute(
text(
"SELECT format_type(a.atttypid, a.atttypmod) "
"FROM pg_catalog.pg_attribute a "
"WHERE a.attrelid = 'vectors.memory'::pg_catalog.regclass "
"AND a.attname = 'embedding'"
)
).scalar_one() == "vectors.vector(768)"
verification.dispose()
finally:
cleanup = create_engine(database_url, isolation_level="AUTOCOMMIT")
with cleanup.connect() as connection:
connection.exec_driver_sql("ALTER ROLE test RESET search_path")
connection.exec_driver_sql(
"SELECT pg_catalog.pg_terminate_backend(pid) FROM pg_catalog.pg_stat_activity "
"WHERE datname = 'vector_hostile' AND pid <> pg_catalog.pg_backend_pid()"
)
connection.exec_driver_sql("DROP DATABASE IF EXISTS vector_hostile")
cleanup.dispose()
admin.dispose()
def test_failed_batch_rolls_back_schema_and_ledger(database_url, tmp_path): def test_failed_batch_rolls_back_schema_and_ledger(database_url, tmp_path):
from tht.cli.vector_migrate_cmd import MigrationError, migrate, migration_status from tht.cli.vector_migrate_cmd import MigrationError, migrate, migration_status
migrations = tmp_path / "failed" migrations = _copy_migrations(tmp_path)
migrations.mkdir() (migrations / "004_first.sql").write_text("CREATE TABLE public.must_rollback (id int);\n")
(migrations / "101_first.sql").write_text("CREATE TABLE public.must_rollback (id int);\n") (migrations / "005_broken.sql").write_text("THIS IS NOT SQL;\n")
(migrations / "102_broken.sql").write_text("THIS IS NOT SQL;\n")
with pytest.raises(MigrationError, match="102_broken.sql"): with pytest.raises(MigrationError, match="005_broken.sql"):
migrate(database_url, migrations) migrate(database_url, migrations)
engine = create_engine(database_url) engine = create_engine(database_url)
@@ -192,12 +290,12 @@ def test_failed_batch_rolls_back_schema_and_ledger(database_url, tmp_path):
assert connection.execute(text("SELECT to_regclass('public.must_rollback')")).scalar() is None assert connection.execute(text("SELECT to_regclass('public.must_rollback')")).scalar() is None
engine.dispose() engine.dispose()
status = migration_status(database_url, migrations) status = migration_status(database_url, migrations)
assert status.applied == () assert [item.version for item in status.applied] == ["001", "002", "003"]
assert [item.version for item in status.pending] == ["101", "102"] assert [item.version for item in status.pending] == ["004", "005"]
def _copy_migrations(tmp_path: Path) -> Path: def _copy_migrations(tmp_path: Path) -> Path:
source = Path(__file__).parents[2] / "migrations" / "vector" source = Path(__file__).parents[2] / "tht" / "migrations" / "vector"
target = tmp_path / "migrations" target = tmp_path / "migrations"
target.mkdir() target.mkdir()
for migration in source.glob("*.sql"): for migration in source.glob("*.sql"):
@@ -0,0 +1,59 @@
import os
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
def test_built_wheel_installs_vector_migrations_and_discovers_cli(tmp_path):
harness = Path(__file__).parents[1]
wheelhouse = tmp_path / "wheelhouse"
target = tmp_path / "site"
wheelhouse.mkdir()
uv = shutil.which("uv")
assert uv is not None, "uv is required to verify the production wheel"
build_env = {**os.environ, "UV_CACHE_DIR": str(tmp_path / "uv-cache")}
subprocess.run(
[
uv,
"build",
"--wheel",
"--out-dir",
str(wheelhouse),
str(harness),
],
check=True,
capture_output=True,
text=True,
env=build_env,
)
wheel = next(wheelhouse.glob("tht-*.whl"))
with zipfile.ZipFile(wheel) as archive:
names = set(archive.namelist())
assert "tht/migrations/vector/001_extensions.sql" in names
assert "tht/migrations/vector/003_roles.sql" in names
subprocess.run(
[sys.executable, "-m", "pip", "install", "--no-deps", "--target", str(target), wheel],
check=True,
capture_output=True,
text=True,
)
env = {**os.environ, "PYTHONPATH": str(target)}
probe = subprocess.run(
[
sys.executable,
"-c",
"from typer.testing import CliRunner; from tht.cli import app; "
"r=CliRunner().invoke(app, ['vector','migrate','--help']); "
"print(r.output); raise SystemExit(r.exit_code)",
],
env=env,
check=False,
capture_output=True,
text=True,
cwd=tmp_path,
)
assert probe.returncode == 0, probe.stderr + probe.stdout
assert "--status" in probe.stdout
+23 -8
View File
@@ -27,7 +27,7 @@ COLLECTION_KINDS = {
} }
ALLOWED_COLLECTIONS = frozenset(COLLECTION_KINDS) ALLOWED_COLLECTIONS = frozenset(COLLECTION_KINDS)
ALLOWED_KINDS = frozenset().union(*COLLECTION_KINDS.values()) ALLOWED_KINDS = frozenset().union(*COLLECTION_KINDS.values())
_VECTOR_DIMENSION = re.compile(r"^vector\((\d+)\)$") _VECTOR_DIMENSION = re.compile(r"^(?:[a-z_][a-z0-9_]*\.)?vector\((\d+)\)$")
def _collection(schema: str, name: str) -> sql.Identifier: def _collection(schema: str, name: str) -> sql.Identifier:
@@ -40,6 +40,14 @@ def _vector_literal(values: list[float]) -> str:
return "[" + ",".join(str(float(value)) for value in values) + "]" return "[" + ",".join(str(float(value)) for value in values) + "]"
def _vector_type(schema: str) -> sql.Identifier:
return sql.Identifier(schema, "vector")
def _cosine_operator(schema: str) -> sql.Composed:
return sql.SQL("OPERATOR({}.<=>)").format(sql.Identifier(schema))
def _validate_collection_kinds(collection: str, kinds: list[str]) -> None: def _validate_collection_kinds(collection: str, kinds: list[str]) -> None:
invalid = set(kinds) - COLLECTION_KINDS[collection] invalid = set(kinds) - COLLECTION_KINDS[collection]
if invalid: if invalid:
@@ -248,9 +256,16 @@ class PgVectorStore:
continue continue
where = sql.SQL(" WHERE kind = ANY(%s)") if collection_kinds else sql.SQL("") where = sql.SQL(" WHERE kind = ANY(%s)") if collection_kinds else sql.SQL("")
query = sql.SQL( query = sql.SQL(
"SELECT metadata, 1 - (embedding <=> %s::vector) AS similarity " "SELECT metadata, 1 - (embedding {} %s::{}) AS similarity "
"FROM {}{} ORDER BY embedding <=> %s::vector LIMIT %s" "FROM {}{} ORDER BY embedding {} %s::{} LIMIT %s"
).format(table, where) ).format(
_cosine_operator(self._schema),
_vector_type(self._schema),
table,
where,
_cosine_operator(self._schema),
_vector_type(self._schema),
)
params = [_vector_literal(embedding)] params = [_vector_literal(embedding)]
if collection_kinds: if collection_kinds:
params.append(collection_kinds) params.append(collection_kinds)
@@ -295,13 +310,13 @@ class PgVectorStore:
raise VectorStoreError("Embedding dimension does not match configured dimension") raise VectorStoreError("Embedding dimension does not match configured dimension")
insert = sql.SQL( insert = sql.SQL(
"INSERT INTO {} (record_key, kind, content_hash, metadata, embedding) " "INSERT INTO {} (record_key, kind, content_hash, metadata, embedding) "
"VALUES (%s, %s, %s, %s::jsonb, %s::vector) " "VALUES (%s, %s, %s, %s::jsonb, %s::{}) "
"ON CONFLICT (record_key) DO NOTHING" "ON CONFLICT (record_key) DO NOTHING"
).format(table) ).format(table, _vector_type(self._schema))
update = sql.SQL( update = sql.SQL(
"UPDATE {} SET kind = %s, content_hash = %s, metadata = %s::jsonb, " "UPDATE {} SET kind = %s, content_hash = %s, metadata = %s::jsonb, "
"embedding = %s::vector, indexed_at = now() WHERE record_key = %s" "embedding = %s::{}, indexed_at = pg_catalog.now() WHERE record_key = %s"
).format(table) ).format(table, _vector_type(self._schema))
raw = engine.raw_connection() raw = engine.raw_connection()
try: try:
with raw.cursor() as cursor: with raw.cursor() as cursor:
+53 -17
View File
@@ -6,6 +6,8 @@ import hashlib
import json import json
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from importlib.resources import files
from importlib.resources.abc import Traversable
from pathlib import Path from pathlib import Path
import typer import typer
@@ -14,7 +16,7 @@ from sqlalchemy.exc import SQLAlchemyError
from tht.cli.vector_cmd import vector_app from tht.cli.vector_cmd import vector_app
MIGRATIONS_DIR = Path(__file__).parents[2] / "migrations" / "vector" MIGRATIONS_DIR = files("tht").joinpath("migrations", "vector")
_MIGRATION_NAME = re.compile(r"^(?P<version>\d+)_(?P<name>[a-z0-9_]+)\.sql$") _MIGRATION_NAME = re.compile(r"^(?P<version>\d+)_(?P<name>[a-z0-9_]+)\.sql$")
_LOCK_KEY = 7_304_708_654_221_909_028 _LOCK_KEY = 7_304_708_654_221_909_028
@@ -27,7 +29,7 @@ class MigrationError(RuntimeError):
class Migration: class Migration:
version: str version: str
name: str name: str
path: Path path: Traversable
checksum: str checksum: str
@@ -38,32 +40,44 @@ class MigrationStatus:
drifted: tuple[Migration, ...] drifted: tuple[Migration, ...]
def _discover(directory: Path) -> tuple[Migration, ...]: def _migration_source(directory: Traversable | Path | str) -> Traversable:
return Path(directory) if isinstance(directory, (str, Path)) else directory
def _discover(directory: Traversable | Path | str) -> tuple[Migration, ...]:
source = _migration_source(directory)
migrations = [] migrations = []
seen_versions: set[str] = set() seen_versions: set[int] = set()
for path in sorted(directory.glob("*.sql")): paths = [path for path in source.iterdir() if path.name.endswith(".sql")]
parsed = []
for path in paths:
match = _MIGRATION_NAME.fullmatch(path.name) match = _MIGRATION_NAME.fullmatch(path.name)
if match is None: if match is None:
raise MigrationError(f"Invalid migration filename: {path.name}") raise MigrationError(f"Invalid migration filename: {path.name}")
version = match.group("version") version = match.group("version")
if version in seen_versions: numeric_version = int(version)
raise MigrationError(f"Duplicate migration version: {version}") if numeric_version in seen_versions:
seen_versions.add(version) raise MigrationError(f"Duplicate migration version: {numeric_version}")
seen_versions.add(numeric_version)
parsed.append((numeric_version, version, match.group("name"), path))
for _, version, name, path in sorted(parsed, key=lambda item: item[0]):
migrations.append( migrations.append(
Migration( Migration(
version=version, version=version,
name=match.group("name"), name=name,
path=path, path=path,
checksum=hashlib.sha256(path.read_bytes()).hexdigest(), checksum=hashlib.sha256(path.read_bytes()).hexdigest(),
) )
) )
if not migrations: if not migrations:
raise MigrationError(f"No migrations found in {directory}") raise MigrationError(f"No migrations found in {source}")
return tuple(migrations) return tuple(migrations)
def _applied(connection) -> dict[str, str]: def _applied(connection) -> dict[str, str]:
exists = connection.execute(text("SELECT to_regclass('public.tht_vector_migrations')")).scalar() exists = connection.execute(
text("SELECT pg_catalog.to_regclass('public.tht_vector_migrations')")
).scalar()
if exists is None: if exists is None:
return {} return {}
return dict( return dict(
@@ -73,16 +87,32 @@ def _applied(connection) -> dict[str, str]:
) )
def _reject_unknown_versions(
migrations: tuple[Migration, ...], applied_checksums: dict[str, str]
) -> None:
local_versions = {migration.version for migration in migrations}
unknown = sorted(
set(applied_checksums) - local_versions,
key=lambda version: (0, int(version)) if version.isdigit() else (1, version),
)
if unknown:
raise MigrationError(
"Database migration versions absent from local manifest: " + ", ".join(unknown)
)
def migration_status( def migration_status(
database_url: str, migrations_dir: Path | str = MIGRATIONS_DIR database_url: str, migrations_dir: Traversable | Path | str = MIGRATIONS_DIR
) -> MigrationStatus: ) -> MigrationStatus:
migrations = _discover(Path(migrations_dir)) migrations = _discover(migrations_dir)
engine = create_engine(database_url) engine = create_engine(database_url)
try: try:
with engine.connect() as connection: with engine.connect() as connection:
connection.exec_driver_sql("SET LOCAL search_path = pg_catalog, pg_temp")
applied_checksums = _applied(connection) applied_checksums = _applied(connection)
finally: finally:
engine.dispose() engine.dispose()
_reject_unknown_versions(migrations, applied_checksums)
applied = tuple( applied = tuple(
migration migration
for migration in migrations for migration in migrations
@@ -100,25 +130,31 @@ def migration_status(
return MigrationStatus(applied=applied, pending=pending, drifted=drifted) return MigrationStatus(applied=applied, pending=pending, drifted=drifted)
def migrate(database_url: str, migrations_dir: Path | str = MIGRATIONS_DIR) -> MigrationStatus: def migrate(
migrations = _discover(Path(migrations_dir)) database_url: str, migrations_dir: Traversable | Path | str = MIGRATIONS_DIR
) -> MigrationStatus:
migrations = _discover(migrations_dir)
engine = create_engine(database_url) engine = create_engine(database_url)
current: Migration | None = None current: Migration | None = None
try: try:
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": _LOCK_KEY}) connection.exec_driver_sql("SET LOCAL search_path = pg_catalog, pg_temp")
connection.execute(
text("SELECT pg_catalog.pg_advisory_xact_lock(:key)"), {"key": _LOCK_KEY}
)
connection.exec_driver_sql( connection.exec_driver_sql(
"""CREATE TABLE IF NOT EXISTS public.tht_vector_migrations ( """CREATE TABLE IF NOT EXISTS public.tht_vector_migrations (
version text PRIMARY KEY, version text PRIMARY KEY,
name text NOT NULL, name text NOT NULL,
checksum text NOT NULL, checksum text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now() applied_at timestamptz NOT NULL DEFAULT pg_catalog.now()
)""" )"""
) )
connection.exec_driver_sql( connection.exec_driver_sql(
"REVOKE ALL ON public.tht_vector_migrations FROM PUBLIC" "REVOKE ALL ON public.tht_vector_migrations FROM PUBLIC"
) )
applied_checksums = _applied(connection) applied_checksums = _applied(connection)
_reject_unknown_versions(migrations, applied_checksums)
drifted = [ drifted = [
item item
for item in migrations for item in migrations
@@ -0,0 +1,3 @@
CREATE SCHEMA IF NOT EXISTS vectors;
REVOKE ALL ON SCHEMA vectors FROM PUBLIC;
CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA vectors;
@@ -1,15 +1,11 @@
CREATE SCHEMA IF NOT EXISTS vectors;
REVOKE ALL ON SCHEMA vectors FROM PUBLIC;
CREATE TABLE IF NOT EXISTS vectors.schema_records ( CREATE TABLE IF NOT EXISTS vectors.schema_records (
id bigserial PRIMARY KEY, id bigserial PRIMARY KEY,
record_key text UNIQUE NOT NULL, record_key text UNIQUE NOT NULL,
kind text NOT NULL, kind text NOT NULL,
content_hash text NOT NULL, content_hash text NOT NULL,
metadata jsonb NOT NULL, metadata jsonb NOT NULL,
embedding vector(768) NOT NULL, embedding vectors.vector(768) NOT NULL,
indexed_at timestamptz NOT NULL DEFAULT now() indexed_at timestamptz NOT NULL DEFAULT pg_catalog.now()
); );
CREATE TABLE IF NOT EXISTS vectors.evidence ( CREATE TABLE IF NOT EXISTS vectors.evidence (
@@ -18,8 +14,8 @@ CREATE TABLE IF NOT EXISTS vectors.evidence (
kind text NOT NULL, kind text NOT NULL,
content_hash text NOT NULL, content_hash text NOT NULL,
metadata jsonb NOT NULL, metadata jsonb NOT NULL,
embedding vector(768) NOT NULL, embedding vectors.vector(768) NOT NULL,
indexed_at timestamptz NOT NULL DEFAULT now() indexed_at timestamptz NOT NULL DEFAULT pg_catalog.now()
); );
CREATE TABLE IF NOT EXISTS vectors.memory ( CREATE TABLE IF NOT EXISTS vectors.memory (
@@ -28,8 +24,8 @@ CREATE TABLE IF NOT EXISTS vectors.memory (
kind text NOT NULL, kind text NOT NULL,
content_hash text NOT NULL, content_hash text NOT NULL,
metadata jsonb NOT NULL, metadata jsonb NOT NULL,
embedding vector(768) NOT NULL, embedding vectors.vector(768) NOT NULL,
indexed_at timestamptz NOT NULL DEFAULT now() indexed_at timestamptz NOT NULL DEFAULT pg_catalog.now()
); );
REVOKE ALL ON ALL TABLES IN SCHEMA vectors FROM PUBLIC; REVOKE ALL ON ALL TABLES IN SCHEMA vectors FROM PUBLIC;
@@ -1,9 +1,9 @@
DO $roles$ DO $roles$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'vector_reader') THEN IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'vector_reader') THEN
CREATE ROLE vector_reader NOLOGIN; CREATE ROLE vector_reader NOLOGIN;
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'vector_writer') THEN IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'vector_writer') THEN
CREATE ROLE vector_writer NOLOGIN; CREATE ROLE vector_writer NOLOGIN;
END IF; END IF;
END END
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
set -eu
image=${1:?usage: test-vector-migration-image.sh IMAGE [PLATFORM]}
platform=${2:-${PLATFORM:-linux/arm64}}
slug=$$
network="thoth-vector-migration-$slug"
database="thoth-vector-db-$slug"
cleanup() {
docker rm --force "$database" >/dev/null 2>&1 || true
docker network rm "$network" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
docker network create "$network" >/dev/null
docker run --detach --rm --platform "$platform" --name "$database" --network "$network" \
-e POSTGRES_DB=thoth -e POSTGRES_USER=thoth_admin -e POSTGRES_PASSWORD=test-only \
pgvector/pgvector:pg16 >/dev/null
attempt=0
until docker exec "$database" pg_isready -U thoth_admin -d thoth >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then
echo "pgvector test database did not become ready" >&2
exit 1
fi
sleep 1
done
database_url="postgresql+psycopg2://thoth_admin:test-only@$database:5432/thoth"
applied=$(docker run --rm --platform "$platform" --network "$network" \
--entrypoint /opt/venv/bin/tht -e THT_VECTOR_ADMIN_URL="$database_url" \
"$image" vector migrate --json)
status=$(docker run --rm --platform "$platform" --network "$network" \
--entrypoint /opt/venv/bin/tht -e THT_VECTOR_ADMIN_URL="$database_url" \
"$image" vector migrate --status --json)
expected='{"applied": ["001", "002", "003"], "drifted": [], "pending": []}'
test "$applied" = "$expected"
test "$status" = "$expected"
echo "core image vector migration discovery/status smoke passed"
+1
View File
@@ -18,6 +18,7 @@ docker buildx build --platform "$platform" --load \
docker run --rm --platform "$platform" --entrypoint /app/docker/smoke/core-smoke.sh \ docker run --rm --platform "$platform" --entrypoint /app/docker/smoke/core-smoke.sh \
"$core_image" "$core_image"
./scripts/test-vector-migration-image.sh "$core_image" "$platform"
docker run --rm --platform "$platform" -e BACKEND_BASE_URL=/api \ docker run --rm --platform "$platform" -e BACKEND_BASE_URL=/api \
"$frontend_image" frontend-config-smoke "$frontend_image" frontend-config-smoke
docker run --rm --platform "$platform" -e BACKEND_BASE_URL= \ docker run --rm --platform "$platform" -e BACKEND_BASE_URL= \