fix: retire active pgvector artifacts

This commit is contained in:
2026-08-08 19:27:05 +02:00
parent 4e3fecbe8e
commit a6dbe1d023
16 changed files with 630 additions and 292 deletions
+21 -22
View File
@@ -141,7 +141,7 @@ an independent 32-minute outer timeout and does not retry a failed command.
Current release status (2026-08-05): clean-root render/setup and the production runtime-binding Current release status (2026-08-05): clean-root render/setup and the production runtime-binding
resolver contracts are green. The server fixture supplies all four private trusted claims, resolver contracts are green. The server fixture supplies all four private trusted claims,
including exact non-admin value `0`, and a focused test proves nginx normalization produces the including exact non-admin value `0`, and a focused test proves nginx normalization produces the
accepted non-admin backend principal. Canonical schema-v2 registry descriptors now pass through accepted non-admin backend principal. Canonical schema-v3 registry descriptors now pass through
one backend-owned, secret-safe runtime handoff for inventory and session execution; canonical one backend-owned, secret-safe runtime handoff for inventory and session execution; canonical
identity and durable session/artifact/index roots are retained. The fresh update-only smoke passed identity and durable session/artifact/index roots are retained. The fresh update-only smoke passed
bad-candidate mutation, automatic `rolled_back` compensation, exact prior-image restoration, bad-candidate mutation, automatic `rolled_back` compensation, exact prior-image restoration,
@@ -181,7 +181,7 @@ docker compose --env-file deploy/env/local.env \
-f deploy/compose.preprocess.yaml --profile preprocess run --rm preprocess-evidence -f deploy/compose.preprocess.yaml --profile preprocess run --rm preprocess-evidence
``` ```
Replace the final service with `preprocess-dwh` when required. The overlay makes each job wait for the vector Replace the final service with `preprocess-dwh` when required. The overlay makes each job wait for the internal Qdrant
service health checks and embedding model initialization; no separate semantic-service startup is service health checks and embedding model initialization; no separate semantic-service startup is
required. required.
@@ -194,37 +194,36 @@ egress policy. Store access key, secret key, and session token as secret referen
deployment configuration—never in Compose environment values or source URIs. Discovery and reads deployment configuration—never in Compose environment values or source URIs. Discovery and reads
are bounded by configured page, object, and byte limits. are bounded by configured page, object, and byte limits.
Create a versioned PostgreSQL custom-format backup (the filename is operator-controlled, so use Create a versioned Qdrant volume backup for one exact Compose project (the filename is
an immutable timestamp or release identifier): operator-controlled, so use an immutable timestamp or release identifier):
```sh ```sh
./scripts/vector-backup.sh \ ./scripts/vector-backup.sh \
--host 127.0.0.1 --port 5432 --database thoth --user thoth_backup \ --project-name thothii \
--password-file /secure/thoth/vector-backup-password \ --output /secure/backups/thoth-qdrant-2026-08-08.tar
--output /secure/backups/thoth-vectors-2026-07-12.dump
``` ```
The dump contains the three allowlisted `vectors` tables, their data and ACLs, plus the The script resolves exactly one Docker volume with the labels
`public.tht_vector_migrations` ledger. Login roles and passwords are deliberately not copied: `com.docker.compose.project=<project>` and `com.docker.compose.volume=qdrant-data`, stops the
provision/reconcile the approved role names on the target first, and install the `vector` `qdrant` service if it is running, archives that volume's persistent contents, then restores the
extension in its `vectors` schema. The target must otherwise contain no vector tables or ledger. prior service state. It never performs global Docker cleanup and refuses to overwrite an existing
archive path.
Restore always names both the currently active source and a target on a physically distinct Restore targets that same exact project-scoped `qdrant-data` volume. Because restore replaces the
PostgreSQL cluster. The script compares PostgreSQL system identity, so host aliases or a different persistent Qdrant data in place, it requires an explicit confirmation that exactly repeats the
database in the active cluster cannot bypass the guard. It refuses a non-empty target unless Compose project name:
`--force-nonempty` is explicit, and the clean restore is one transaction:
```sh ```sh
./scripts/vector-restore.sh \ ./scripts/vector-restore.sh \
--active-host vector-db --active-database thoth --active-user thoth_backup \ --project-name thothii \
--active-password-file /secure/thoth/vector-active-password \ --input /secure/backups/thoth-qdrant-2026-08-08.tar \
--target-host vector-db-restore --target-database thoth --target-user thoth_restore \ --confirm-project thothii
--target-password-file /secure/thoth/vector-restore-password \
--input /secure/backups/thoth-vectors-2026-07-12.dump
``` ```
After restore, run `tht vector migrate --status --json`, adapter health, and a known retrieval The restore script stops `qdrant`, validates the exact labeled target, stages the current volume
query against the target before changing any migration/export endpoint. contents for rollback, extracts the requested archive into the volume, and then returns the
service to its prior running state. After restore, run the backend health checks and a known
retrieval query before reopening write traffic.
## Production trust boundary and secrets ## Production trust boundary and secrets
+6 -1
View File
@@ -69,7 +69,12 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
const hub = deps?.hub ?? new SseHub(); const hub = deps?.hub ?? new SseHub();
const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry); const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry);
const workspaceDiagnoser = deps?.workspaceDiagnoser const workspaceDiagnoser = deps?.workspaceDiagnoser
?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs); ?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs, undefined, {
internalQdrantUrl: config.internalQdrantUrl,
internalEmbeddingUrl: config.internalEmbeddingUrl,
internalEmbeddingModel: config.internalEmbeddingModel,
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
});
const workspaceRuntimeSupport = deps?.workspaceRuntimeSupport ?? ((workspace: WorkspaceDescriptor) => ( const workspaceRuntimeSupport = deps?.workspaceRuntimeSupport ?? ((workspace: WorkspaceDescriptor) => (
supportsSessionRuntime(resolveRuntimeBindings( supportsSessionRuntime(resolveRuntimeBindings(
workspace, workspace,
+230 -2
View File
@@ -16,6 +16,7 @@ import {
type WorkspaceDescriptor, type WorkspaceDescriptor,
} from "./schema.js"; } from "./schema.js";
import type { WorkspaceErrorCode } from "./types.js"; import type { WorkspaceErrorCode } from "./types.js";
import type { SemanticRuntimeConfig } from "./runtime-renderer.js";
export interface Diagnostic { export interface Diagnostic {
level: "error" | "warning" | "info"; level: "error" | "warning" | "info";
@@ -395,6 +396,26 @@ export function createConcreteDiagnosticAdapters(
} }
}, },
async inspectVector(request) { async inspectVector(request) {
if (request.transport === "rest_api" && request.baseUrl && request.diagnostic === undefined) {
const response = await fetch(new URL(`/collections/${request.collection}`, `${request.baseUrl}/`).toString(), {
method: "GET",
signal: request.signal,
redirect: "error",
});
const payload = await response.json().catch(() => undefined) as {
result?: { config?: { params?: { vectors?: { size?: unknown; distance?: unknown } } } };
} | undefined;
const size = payload?.result?.config?.params?.vectors?.size;
const distance = payload?.result?.config?.params?.vectors?.distance;
if (!response.ok || !Number.isInteger(size) || typeof distance !== "string") {
throw new Error("vector metadata adapter is unavailable");
}
return {
collection: request.collection,
dimensions: size as number,
distance: distance.toLowerCase() as VectorDiagnosticResult["distance"],
};
}
if (request.transport === "pgvector_direct" || request.transport === "ssh_tunnel") { if (request.transport === "pgvector_direct" || request.transport === "ssh_tunnel") {
const resource = request.resource; const resource = request.resource;
if (!request.host || !request.port || !request.user || !request.credentialFile if (!request.host || !request.port || !request.user || !request.credentialFile
@@ -440,6 +461,21 @@ export function createConcreteDiagnosticAdapters(
}; };
}, },
async probeEmbedding(request) { async probeEmbedding(request) {
if (!request.diagnostic && !request.tlsCaFile) {
const response = await fetch(new URL("/api/embed", `${request.baseUrl}/`).toString(), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: request.model, input: "diagnostic" }),
signal: request.signal,
redirect: "error",
});
const payload = await response.json().catch(() => undefined) as {
embeddings?: unknown[];
} | undefined;
const vector = Array.isArray(payload?.embeddings) ? payload?.embeddings[0] : undefined;
if (!response.ok || !Array.isArray(vector)) throw new Error("embedding probe failed");
return { available: true, dimensions: vector.length };
}
if (!request.diagnostic || request.tlsCaFile) throw new Error("embedding probe failed"); if (!request.diagnostic || request.tlsCaFile) throw new Error("embedding probe failed");
const response = await fetch(resolveDiagnosticUrl(request.baseUrl, request.diagnostic.path).toString(), { const response = await fetch(resolveDiagnosticUrl(request.baseUrl, request.diagnostic.path).toString(), {
method: request.diagnostic.method, method: request.diagnostic.method,
@@ -492,8 +528,31 @@ export function createConcreteDiagnosticAdapters(
export function createProductionWorkspaceDiagnoser( export function createProductionWorkspaceDiagnoser(
timeoutMs: number, timeoutMs: number,
adapters: DiagnosticAdapters = createConcreteDiagnosticAdapters(), adapters: DiagnosticAdapters = createConcreteDiagnosticAdapters(),
semanticRuntime: SemanticRuntimeConfig = {
internalQdrantUrl: "http://qdrant:6333",
internalEmbeddingUrl: "http://embedding:11434",
internalEmbeddingModel: "qwen3-embedding:0.6b",
internalEmbeddingDimensions: 1024,
},
) { ) {
return createWorkspaceDiagnoser(adapters, { timeoutMs }); const legacyDiagnoser = createWorkspaceDiagnoser(adapters, { timeoutMs });
return async (
workspace: WorkspaceDescriptor,
bindings: RuntimeBindings,
options: { writeProbe: boolean },
): Promise<WorkspaceDiagnostics> => {
const descriptor = validateWorkspaceDescriptor(workspace);
if (descriptor.workspace.schema_version !== 3) {
return await legacyDiagnoser(descriptor, bindings, options);
}
return await diagnoseSchemaV3Workspace(
descriptor as Extract<WorkspaceDescriptor, { workspace: { schema_version: 3 } }>,
bindings,
adapters,
timeoutMs,
semanticRuntime,
);
};
} }
function boundedTimeout(value: number | undefined, fallback: number): number { function boundedTimeout(value: number | undefined, fallback: number): number {
@@ -526,6 +585,22 @@ function hasRequiredConnectorChecks(result: ConnectorDiagnosticResult, resource:
return result.resolved && result.tlsVerified && result.authenticated && sameResource(resource, result.resource); return result.resolved && result.tlsVerified && result.authenticated && sameResource(resource, result.resource);
} }
function hasMatchingVectorMetadata(
actual: VectorDiagnosticResult,
expected: { collection: string; dimensions: number; distance: "cosine" | "l2" | "inner_product" },
): boolean {
return actual.collection === expected.collection
&& actual.dimensions === expected.dimensions
&& actual.distance === expected.distance;
}
function hasMatchingEmbeddingMetadata(
actual: EmbeddingDiagnosticResult,
expected: { dimensions: number },
): boolean {
return actual.available && actual.dimensions === expected.dimensions;
}
function diagnosticError(code: WorkspaceErrorCode, field?: string): Diagnostic { function diagnosticError(code: WorkspaceErrorCode, field?: string): Diagnostic {
return { return {
level: "error", level: "error",
@@ -542,7 +617,7 @@ function diagnosticError(code: WorkspaceErrorCode, field?: string): Diagnostic {
} }
function bindingName( function bindingName(
workspace: WorkspaceV2, workspace: WorkspaceDescriptor,
role: "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING", role: "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING",
suffix: string, suffix: string,
): string { ): string {
@@ -558,6 +633,159 @@ function numericBinding(binding: Record<string, string>, name: string): number |
return Number.isInteger(value) && value > 0 && value <= 65_535 ? value : undefined; return Number.isInteger(value) && value > 0 && value <= 65_535 ? value : undefined;
} }
async function diagnoseSchemaV3Workspace(
descriptor: Extract<WorkspaceDescriptor, { workspace: { schema_version: 3 } }>,
bindings: RuntimeBindings,
adapters: DiagnosticAdapters,
timeoutMs: number,
semanticRuntime: SemanticRuntimeConfig,
): Promise<WorkspaceDiagnostics> {
const diagnostics = [...bindings.dwh.missing]
.sort()
.map((field) => diagnosticError("binding_missing", field));
if (diagnostics.length > 0) {
return { activatable: false, diagnostics };
}
const dwhTimeout = boundedTimeout(descriptor.dwh.timeout_ms, timeoutMs);
const vectorTimeout = timeoutMs;
const embeddingTimeout = timeoutMs;
let activatable = true;
const dwhValues = bindings.dwh.values;
const dwhField = (suffix: string) => bindingName(descriptor, "DWH", suffix);
const dwhResource = { database: descriptor.dwh.database, schema: descriptor.dwh.schema };
let dwhRequest: ConnectorDiagnosticRequest | SshTunnelRequest | undefined;
if (bindings.dwh.transport === "rest_api") {
const diagnostic = descriptor.diagnostics?.dwh_rest;
const baseUrl = dwhValues[dwhField("BASE_URL")];
if (diagnostic && baseUrl) {
const credentialFile = diagnostic.auth === "none" ? undefined : dwhValues[dwhField("API_KEY_FILE")];
if (diagnostic.auth === "none" || credentialFile !== undefined) {
dwhRequest = {
role: "dwh",
transport: "rest_api",
baseUrl,
credentialFile,
tlsCaFile: dwhValues[dwhField("TLS_CA_FILE")],
resource: dwhResource,
timeoutMs: dwhTimeout,
signal: new AbortController().signal,
diagnostic,
};
}
}
} else if (bindings.dwh.transport === "postgres_direct") {
const host = dwhValues[dwhField("HOST")];
const port = numericBinding(dwhValues, dwhField("PORT"));
const user = dwhValues[dwhField("USER")];
const credentialFile = dwhValues[dwhField("PASSWORD_FILE")];
if (host && port && user && credentialFile) {
dwhRequest = {
role: "dwh",
transport: "postgres_direct",
host,
port,
user,
credentialFile,
tlsCaFile: dwhValues[dwhField("TLS_CA_FILE")],
resource: dwhResource,
timeoutMs: dwhTimeout,
signal: new AbortController().signal,
};
}
} else {
const sshHost = dwhValues[dwhField("SSH_HOST")];
const sshPort = numericBinding(dwhValues, dwhField("SSH_PORT"));
const sshUser = dwhValues[dwhField("SSH_USER")];
const privateKeyFile = dwhValues[dwhField("SSH_PRIVATE_KEY_FILE")];
const knownHostsFile = dwhValues[dwhField("SSH_KNOWN_HOSTS_FILE")];
const targetHost = dwhValues[dwhField("SSH_TARGET_HOST")];
const targetPort = numericBinding(dwhValues, dwhField("SSH_TARGET_PORT"));
if (sshHost && sshPort && sshUser && privateKeyFile && knownHostsFile && targetHost && targetPort) {
dwhRequest = {
sshHost,
sshPort,
sshUser,
privateKeyFile,
knownHostsFile,
targetHost,
targetPort,
localHost: "127.0.0.1",
localPort: 0,
timeoutMs: dwhTimeout,
signal: new AbortController().signal,
};
}
}
if (!dwhRequest || "sshHost" in dwhRequest) {
diagnostics.push(diagnosticError("workspace_not_activatable"));
return { activatable: false, diagnostics };
}
try {
const dwhResult = await withTimeout(dwhTimeout, (signal) => adapters.probeConnector({
...dwhRequest,
signal,
timeoutMs: dwhTimeout,
}));
if (!hasRequiredConnectorChecks(dwhResult, dwhRequest.resource)) {
diagnostics.push(diagnosticError("connector_unavailable"));
activatable = false;
}
} catch {
diagnostics.push(diagnosticError("connector_unavailable"));
activatable = false;
}
try {
const vector = await withTimeout(vectorTimeout, (signal) => adapters.inspectVector({
transport: "rest_api",
baseUrl: semanticRuntime.internalQdrantUrl,
collection: descriptor.semantic_index.vector_store.collection,
dimensions: descriptor.semantic_index.vector_store.dimensions,
distance: descriptor.semantic_index.vector_store.distance,
timeoutMs: vectorTimeout,
signal,
}));
if (!hasMatchingVectorMetadata(vector, descriptor.semantic_index.vector_store)) {
diagnostics.push(diagnosticError("semantic_index_incompatible"));
activatable = false;
}
} catch {
diagnostics.push(diagnosticError("connector_unavailable"));
activatable = false;
}
try {
const embedding = await withTimeout(embeddingTimeout, (signal) => adapters.probeEmbedding({
baseUrl: semanticRuntime.internalEmbeddingUrl,
model: semanticRuntime.internalEmbeddingModel,
timeoutMs: embeddingTimeout,
signal,
}));
if (
semanticRuntime.internalEmbeddingModel !== descriptor.semantic_index.embedding.model
|| semanticRuntime.internalEmbeddingDimensions !== descriptor.semantic_index.embedding.dimensions
|| !hasMatchingEmbeddingMetadata(embedding, {
dimensions: descriptor.semantic_index.embedding.dimensions,
})
) {
diagnostics.push(diagnosticError("semantic_index_incompatible"));
activatable = false;
}
} catch {
diagnostics.push(diagnosticError("connector_unavailable"));
activatable = false;
}
return {
activatable,
diagnostics: diagnostics.length > 0 ? diagnostics : [{ level: "info", code: "binding_ok", message: "Installation bindings and diagnostics succeeded." }],
};
}
function diagnosticsForMissingBindings( function diagnosticsForMissingBindings(
workspace: WorkspaceV2, workspace: WorkspaceV2,
bindings: RuntimeBindings, bindings: RuntimeBindings,
@@ -160,6 +160,38 @@ const writerBindings: RuntimeBindings = {
}, },
}; };
const workspaceV3 = parseWorkspaceYaml(`workspace:
schema_version: 3
id: psd-clinical
name: Policlinico San Donato
language: it
dwh:
engine: postgres
database: warehouse
schema: datawarehouse
timeout_ms: 8000
supported_transports: [postgres_direct, rest_api]
semantic_index:
vector_store:
engine: qdrant
collection: psd-clinical
dimensions: 1024
distance: cosine
embedding:
provider: ollama_internal
model: qwen3-embedding:0.6b
dimensions: 1024
llm_policy:
allowed: [zai/glm-5.2]
`);
const bindingsV3: RuntimeBindings = {
dwh: bindings.dwh,
vector: { transport: "rest_api", missing: [], values: {} },
vectorWriter: { transport: "rest_api", missing: [], values: {} },
embedding: { transport: "rest_api", missing: [], values: {} },
};
function successfulAdapters(overrides: Partial<DiagnosticAdapters> = {}): DiagnosticAdapters { function successfulAdapters(overrides: Partial<DiagnosticAdapters> = {}): DiagnosticAdapters {
return { return {
probeConnector: vi.fn(async (request) => ({ probeConnector: vi.fn(async (request) => ({
@@ -843,6 +875,63 @@ test("constructs the production diagnoser with the configured timeout and inject
expect(adapters.probeEmbedding).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 1234 })); expect(adapters.probeEmbedding).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 1234 }));
}); });
test("diagnoses a schema-v3 workspace through internal Qdrant and embedding config without workspace semantic bindings", async () => {
const adapters = successfulAdapters({
inspectVector: vi.fn(async () => ({
collection: "psd-clinical",
dimensions: 1024,
distance: "cosine",
})),
probeEmbedding: vi.fn(async () => ({ available: true, dimensions: 1024 })),
});
const result = await createProductionWorkspaceDiagnoser(1234, adapters, {
internalQdrantUrl: "http://qdrant:6333",
internalEmbeddingUrl: "http://embedding:11434",
internalEmbeddingModel: "qwen3-embedding:0.6b",
internalEmbeddingDimensions: 1024,
})(workspaceV3, bindingsV3, { writeProbe: false });
expect(result.activatable).toBe(true);
expect(adapters.inspectVector).toHaveBeenCalledWith(expect.objectContaining({
transport: "rest_api",
baseUrl: "http://qdrant:6333",
collection: "psd-clinical",
dimensions: 1024,
distance: "cosine",
timeoutMs: 1234,
}));
expect(adapters.probeEmbedding).toHaveBeenCalledWith(expect.objectContaining({
baseUrl: "http://embedding:11434",
model: "qwen3-embedding:0.6b",
timeoutMs: 1234,
}));
expect(adapters.probeConnector).toHaveBeenCalledTimes(1);
});
test("fails closed for schema-v3 when internal semantic diagnostics do not match descriptor identity", async () => {
const adapters = successfulAdapters({
inspectVector: vi.fn(async () => ({
collection: "wrong-collection",
dimensions: 1024,
distance: "cosine",
})),
probeEmbedding: vi.fn(async () => ({ available: true, dimensions: 1024 })),
});
const result = await createProductionWorkspaceDiagnoser(1234, adapters, {
internalQdrantUrl: "http://qdrant:6333",
internalEmbeddingUrl: "http://embedding:11434",
internalEmbeddingModel: "qwen3-embedding:0.6b",
internalEmbeddingDimensions: 1024,
})(workspaceV3, bindingsV3, { writeProbe: false });
expect(result.activatable).toBe(false);
expect(result.diagnostics).toContainEqual(expect.objectContaining({
code: "semantic_index_incompatible",
}));
});
test("retries bounded cleanup after a write-probe removal times out", async () => { test("retries bounded cleanup after a write-probe removal times out", async () => {
const adapters = successfulAdapters({ const adapters = successfulAdapters({
removeDiagnosticRecord: vi.fn(() => new Promise<void>(() => undefined)), removeDiagnosticRecord: vi.fn(() => new Promise<void>(() => undefined)),
+9 -15
View File
@@ -35,22 +35,16 @@ evidence:
source_root: ${THT_DOCS_ROOT} source_root: ${THT_DOCS_ROOT}
evidence_dir: evidence evidence_dir: evidence
resources:
vector:
engine: qdrant
base_url: http://qdrant:6333
collection: example
embeddings: embeddings:
base_url: ${THT_OLLAMA_URL} provider: ollama_internal
model: nomic-embed-text-v2-moe base_url: http://embedding:11434
dim: 768 model: qwen3-embedding:0.6b
batch_size: 32 dimensions: 1024
vectors:
type: thoth_vector_http
reader:
base_url: ${THT_VEC_REST_URL}
api_key: ${THT_VEC_API_KEY}
ssl_ca: ${THT_SSL_CA}
writer:
base_url: ${THT_VEC_REST_URL}
api_key: ${THT_VEC_WRITE_API_KEY}
ssl_ca: ${THT_SSL_CA}
vector: vector:
max_chunk_chars: 4000 max_chunk_chars: 4000
-48
View File
@@ -1,48 +0,0 @@
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_file: ${THT_VECTOR_READER_PASSWORD_FILE}
writer:
host: vector-db
port: 5432
database: ${THT_VECTOR_DATABASE}
schema: vectors
user: ${THT_VECTOR_WRITER_USER}
password_file: ${THT_VECTOR_WRITER_PASSWORD_FILE}
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
+13 -19
View File
@@ -1,4 +1,4 @@
language: it language: en
dwh: dwh:
type: thoth_rest type: thoth_rest
@@ -10,31 +10,25 @@ dwh:
api_key: ${THT_DWH_API_KEY} api_key: ${THT_DWH_API_KEY}
ssl_ca: ${THT_SSL_CA} ssl_ca: ${THT_SSL_CA}
vectors:
type: thoth_vector_http
reader:
base_url: ${THT_VEC_REST_URL}
api_key: ${THT_VEC_API_KEY}
ssl_ca: ${THT_SSL_CA}
writer:
base_url: ${THT_VEC_WRITE_REST_URL}
api_key: ${THT_VEC_WRITE_API_KEY}
ssl_ca: ${THT_SSL_CA}
roots: roots:
artifacts: /data/workspaces/psd/runtime-v2/artifacts artifacts: /data/workspaces/generic/artifacts
indexes: /data/workspaces/psd/runtime-v2/indexes indexes: /data/workspaces/generic/indexes
sessions: /data/workspaces/psd/sessions sessions: /data/workspaces/generic/sessions
evidence: evidence:
source_root: ${THT_DOCS_ROOT} source_root: ${THT_DOCS_ROOT}
evidence_dir: evidence evidence_dir: evidence
resources:
vector:
engine: qdrant
base_url: http://qdrant:6333
collection: generic
embeddings: embeddings:
base_url: ${THT_OLLAMA_URL} provider: ollama_internal
model: nomic-embed-text-v2-moe base_url: http://embedding:11434
dim: 768 model: qwen3-embedding:0.6b
batch_size: 32 dimensions: 1024
execution: execution:
allow: [cte_test, explain, preview, aggregate, export] allow: [cte_test, explain, preview, aggregate, export]
@@ -29,8 +29,13 @@ roots:
indexes: indexes indexes: indexes
sessions: sessions sessions: sessions
resources:
vector:
engine: qdrant
base_url: http://qdrant:6333
collection: server-sessions
embeddings: embeddings:
base_url: ${THT_OLLAMA_URL} provider: ollama_internal
model: nomic-embed-text-v2-moe base_url: http://embedding:11434
dim: 768 model: qwen3-embedding:0.6b
batch_size: 32 dimensions: 1024
+10 -9
View File
@@ -5,9 +5,10 @@ Git-backed workspace source of truth, installation-local connector bindings, and
the [Pi management manual](pi-management.md) for provider configuration and image recovery. the [Pi management manual](pi-management.md) for provider configuration and image recovery.
This guide runs a single-user ThothII registry on Docker Desktop (macOS or Windows) or a local This guide runs a single-user ThothII registry on Docker Desktop (macOS or Windows) or a local
Linux Docker Engine. It is intentionally loopback-only. Git is shared; the checkout, connector Linux Docker Engine. It is intentionally loopback-only. Git is shared; the checkout, DWH
bindings, credentials, and session data are local. Never put credentials in workspace YAML, Git, bindings, credentials, and session data are local, while internal Qdrant/Ollama ship in the
browser drafts, diagnostics, or `.env.example`. Compose stack. Never put credentials in workspace YAML, Git, browser drafts, diagnostics, or
`.env.example`.
## Prerequisites ## Prerequisites
@@ -60,7 +61,7 @@ and branch are non-secret; every `*_FILE` is a local path whose content never en
| Location | Contains | Never contains | | Location | Contains | Never contains |
| --- | --- | --- | | --- | --- | --- |
| Git workspace repository | schema v2 YAML, generated binding names, LLM policy, model/index identity | installation hostnames, keys, passwords, certificates, SSH keys | | Git workspace repository | schema v3 YAML, generated binding names, LLM policy, and semantic-index identity | installation hostnames, keys, passwords, certificates, SSH keys |
| local `.env` | remote, branch, installation ID, `THT_WORKSPACE_BINDINGS_ENV_FILE`, and secret source paths | secret contents or `THT_WS_*` values | | local `.env` | remote, branch, installation ID, `THT_WORKSPACE_BINDINGS_ENV_FILE`, and secret source paths | secret contents or `THT_WS_*` values |
| workspace bindings env file | only `THT_WS_*` transport, endpoint, user, and `/run/secrets/...` path bindings | secret contents or unrelated application settings | | workspace bindings env file | only `THT_WS_*` transport, endpoint, user, and `/run/secrets/...` path bindings | secret contents or unrelated application settings |
| local secret directory | Git credentials/key, known hosts, CA, connector secret files | a copied registry checkout | | local secret directory | Git credentials/key, known hosts, CA, connector secret files | a copied registry checkout |
@@ -168,13 +169,13 @@ curl --fail --silent http://127.0.0.1:8787/workspaces
The first status request clones, validates all descriptors, and atomically activates a snapshot. The first status request clones, validates all descriptors, and atomically activates a snapshot.
Use `POST /workspace-registry/pull` to fetch later revisions. Run workspace diagnostics only after Use `POST /workspace-registry/pull` to fetch later revisions. Run workspace diagnostics only after
required bindings are mounted. The optional writer probe uses a distinct writer file and removes required DWH bindings are mounted. Schema-v3 diagnostics probe the internal Qdrant/Ollama
its uniquely named temporary record; ordinary diagnostics are read-only. services through backend config; ordinary diagnostics are read-only.
To migrate an existing legacy descriptor, create/clone an empty private remote, set the absolute To migrate an existing legacy descriptor, create/clone an empty private remote, set the absolute
`THT_SOURCE_ROOT`, transform with absolute paths, review the schema-v1 result, explicitly add `THT_SOURCE_ROOT`, transform with absolute paths, review the schema-v1 result, explicitly produce
vector database/schema and the complete schema-v2 contract, then commit/push. The transformer the reviewed schema-v3 contract, then commit/push. The transformer never imports `${ENV}` values
never imports `${ENV}` values or secrets. or secrets.
```sh ```sh
THT_SOURCE_ROOT=/absolute/path/to/ThothII THT_SOURCE_ROOT=/absolute/path/to/ThothII
+10 -9
View File
@@ -32,7 +32,7 @@ targets with runtime ownership without copying secret or tracked file contents i
state. Rerun it after a restore and before Compose or `thothctl` startup; it is idempotent and does state. Rerun it after a restore and before Compose or `thothctl` startup; it is idempotent and does
not overwrite existing targets. not overwrite existing targets.
Permit outbound TCP only to approved Git/Gitea, DWH, vector, embedding, and bastion endpoints. Permit outbound TCP only to approved Git/Gitea, DWH, Qdrant, embedding, and bastion endpoints.
Allow inbound traffic only from the reverse proxy/Docker network. Do not give the runtime service Allow inbound traffic only from the reverse proxy/Docker network. Do not give the runtime service
account Gitea administration, database-superuser rights, or a shell in the Git host. account Gitea administration, database-superuser rights, or a shell in the Git host.
@@ -40,7 +40,7 @@ account Gitea administration, database-superuser rights, or a shell in the Git h
Create a private Gitea (or compatible Git) repository such as `platform/thoth-workspaces`. Protect Create a private Gitea (or compatible Git) repository such as `platform/thoth-workspaces`. Protect
`main` according to the release policy and grant the ThothII publisher only the intended repository `main` according to the release policy and grant the ThothII publisher only the intended repository
scope. Commit canonical schema-v2 descriptors and generated `.md`/`.env.example` artifacts only; scope. Commit canonical schema-v3 descriptors and generated `.md`/`.env.example` artifacts only;
do not commit installation bindings or secret material. do not commit installation bindings or secret material.
For SSH, create a least-privilege deploy key, record Gitea's host key in managed known-hosts, and For SSH, create a least-privilege deploy key, record Gitea's host key in managed known-hosts, and
@@ -49,7 +49,7 @@ machine credential in the secret manager and mount the Gitea/private CA separate
Gitea admin credential in the application. Gitea admin credential in the application.
Bootstrap an empty remote from a temporary review clone: migrate legacy descriptors, review their Bootstrap an empty remote from a temporary review clone: migrate legacy descriptors, review their
schema-v2 identity and generated artifacts, commit, and push `main`. The running server is not an schema-v3 identity and generated artifacts, commit, and push `main`. The running server is not an
authoring environment for migration. authoring environment for migration.
## Git credentials, CA, SSH key, and known-hosts mounts ## Git credentials, CA, SSH key, and known-hosts mounts
@@ -78,8 +78,8 @@ rendered Compose output.
## Shared Git values, local bindings, and secret files ## Shared Git values, local bindings, and secret files
Git describes workspace schema, immutable ID, DWH/vector identity, semantic-index dimensions and Git describes workspace schema, immutable ID, DWH identity, semantic-index dimensions and
distance, embedding contract, and LLM policy. The installation supplies remote/branch/installation distance, internal embedding contract, and LLM policy. The installation supplies remote/branch/installation
ID and one absolute `THT_WORKSPACE_BINDINGS_ENV_FILE` containing only `THT_WS_*` transport, ID and one absolute `THT_WORKSPACE_BINDINGS_ENV_FILE` containing only `THT_WS_*` transport,
endpoint, user, and `/run/secrets/...` path bindings. The base Compose loads that file only into endpoint, user, and `/run/secrets/...` path bindings. The base Compose loads that file only into
`core`. Secret contents are only in host files, never the values stored in Git or browser-local `core`. Secret contents are only in host files, never the values stored in Git or browser-local
@@ -95,8 +95,9 @@ The runtime registry layout is persistent and must be backed up together:
``` ```
Variable names derive from the immutable ID: `north-star-research` becomes `NORTH_STAR_RESEARCH`, producing Variable names derive from the immutable ID: `north-star-research` becomes `NORTH_STAR_RESEARCH`, producing
`THT_WS_NORTH_STAR_RESEARCH_DWH_PASSWORD_FILE`. A declared vector writer uses the distinct `THT_WS_NORTH_STAR_RESEARCH_DWH_PASSWORD_FILE`. Keep the bindings file limited to DWH transport,
`THT_WS_NORTH_STAR_RESEARCH_VECTOR_WRITER_API_KEY_FILE`; a reader file is never a writer substitute. endpoint, user, and secret-path values; internal semantic services are supplied by Compose and do
not require workspace-local vector or embedding bindings.
Copy [the bindings env example](examples/workspace-bindings.env.example) to the protected operator Copy [the bindings env example](examples/workspace-bindings.env.example) to the protected operator
directory. Every path-valued `*_FILE` entry needs an absolute host-only `*_SOURCE` path. Generate directory. Every path-valued `*_FILE` entry needs an absolute host-only `*_SOURCE` path. Generate
the untracked connector override from those files during bootstrap; do not copy or maintain a the untracked connector override from those files during bootstrap; do not copy or maintain a
@@ -216,8 +217,8 @@ For upgrades, record active status/head, finish active work, use the documented
proxy traffic. proxy traffic.
For legacy descriptor migration, use a temporary review clone and the legacy transformer with absolute paths. For legacy descriptor migration, use a temporary review clone and the legacy transformer with absolute paths.
Its schema-v1 output is `migration_required`; explicitly supply vector database/schema, collection Its schema-v1 output is `migration_required`; explicitly supply collection identity, diagnostics,
identity, diagnostics, and the reviewed v2 contract before commit. Never import `${ENV}` values or and the reviewed v3 contract before commit. Never import `${ENV}` values or
copy secret files. copy secret files.
After valid bootstrap, Git outage retains the active snapshot with `degraded: true`. Repair After valid bootstrap, Git outage retains the active snapshot with `degraded: true`. Repair
+3 -2
View File
@@ -61,8 +61,9 @@ for profile in local server; do
const fs = require("fs"); const fs = require("fs");
const [path, profile] = process.argv.slice(2); const [path, profile] = process.argv.slice(2);
const config = JSON.parse(fs.readFileSync(path, "utf8")); const config = JSON.parse(fs.readFileSync(path, "utf8"));
if (Object.keys(config.services).sort().join(",") !== "core,frontend") { const expected = "core,embedding,embedding-model-init,frontend,qdrant";
throw new Error(profile + ": install stack must be exactly core,frontend"); if (Object.keys(config.services).sort().join(",") !== expected) {
throw new Error(profile + ": install stack must be exactly " + expected);
} }
if (!config.services.core.secrets?.some((secret) => secret.target === "thothii.secrets")) { if (!config.services.core.secrets?.some((secret) => secret.target === "thothii.secrets")) {
throw new Error(profile + ": install stack lacks the runtime secret bundle"); throw new Error(profile + ": install stack lacks the runtime secret bundle");
+9 -1
View File
@@ -28,13 +28,17 @@ new_fixture() {
printf '%s\n' '// generic frontend configuration' >"$fixture/repository/frontend/vite.config.ts" printf '%s\n' '// generic frontend configuration' >"$fixture/repository/frontend/vite.config.ts"
printf '%s\n' '// explicit descriptor migration module may mention pgvector during conversion' \ printf '%s\n' '// explicit descriptor migration module may mention pgvector during conversion' \
>"$fixture/repository/backend/src/workspaces/migrate-legacy.ts" >"$fixture/repository/backend/src/workspaces/migrate-legacy.ts"
printf '%s\n' 'language: en' 'vectors: { type: qdrant, base_url: http://qdrant:6333, collection: demo }' \
>"$fixture/repository/deploy/workspaces/example.yaml"
printf '%s\n' '# qdrant backup helper' >"$fixture/repository/scripts/vector-backup.sh"
printf '%s\n' '# qdrant restore helper' >"$fixture/repository/scripts/vector-restore.sh"
# These are the three intentionally allowed categories from the Task 10 boundary. # These are the three intentionally allowed categories from the Task 10 boundary.
printf '%s\n' 'historical omics_portal and Chirone record' \ printf '%s\n' 'historical omics_portal and Chirone record' \
>"$fixture/repository/docs/superpowers/plans/legacy.md" >"$fixture/repository/docs/superpowers/plans/legacy.md"
printf '%s\n' 'historical pgvector rollout note' \ printf '%s\n' 'historical pgvector rollout note' \
>"$fixture/repository/docs/superpowers/specs/history.md" >"$fixture/repository/docs/superpowers/specs/history.md"
printf '%s\n' 'id: psd' >"$fixture/repository/deploy/workspaces/psd.yaml.example" printf '%s\n' 'id: generic' >"$fixture/repository/deploy/workspaces/psd.yaml.example"
printf '%s\n' '# migrate PSD sessions from /home/chirone' \ printf '%s\n' '# migrate PSD sessions from /home/chirone' \
>"$fixture/repository/docker/session-migrate.sh" >"$fixture/repository/docker/session-migrate.sh"
} }
@@ -77,6 +81,10 @@ assert_detected scripts/run-stack.sh 'export THT_VECTOR_READER_PASSWORD_FILE=/ru
assert_detected deploy/env/local.env.example 'THT_OLLAMA_URL=http://ollama.example.invalid:11434' assert_detected deploy/env/local.env.example 'THT_OLLAMA_URL=http://ollama.example.invalid:11434'
assert_detected scripts/generate-override.sh 'THT_WS_NORTH_STAR_RESEARCH_VECTOR_API_KEY_SOURCE=/tmp/vector-key' assert_detected scripts/generate-override.sh 'THT_WS_NORTH_STAR_RESEARCH_VECTOR_API_KEY_SOURCE=/tmp/vector-key'
assert_detected scripts/test-contract.sh 'docker compose -f deploy/compose.local-vector.yaml --profile local-vector config' assert_detected scripts/test-contract.sh 'docker compose -f deploy/compose.local-vector.yaml --profile local-vector config'
assert_detected deploy/workspaces/local-vector.yaml 'vectors: { type: pgvector_direct }'
assert_detected deploy/workspaces/example.yaml 'embeddings: { base_url: ${THT_OLLAMA_URL} }'
assert_detected scripts/vector-backup.sh 'pg_dump --format=custom'
assert_detected scripts/vector-restore.sh 'pg_restore --single-transaction'
new_fixture new_fixture
mkdir -p "$fixture/bin" mkdir -p "$fixture/bin"
+4 -4
View File
@@ -28,7 +28,7 @@ for file in .dockerignore compose.yaml docker-compose.dev.yml frontend/vite.conf
done done
if [[ -d deploy ]]; then if [[ -d deploy ]]; then
while IFS= read -r -d '' file; do runtime_files+=("${file#./}"); done < <( while IFS= read -r -d '' file; do runtime_files+=("${file#./}"); done < <(
find deploy -type f ! -path 'deploy/workspaces/*' -print0 find deploy -type f -print0
) )
fi fi
if [[ -d docker ]]; then if [[ -d docker ]]; then
@@ -57,7 +57,7 @@ if [[ -d scripts ]]; then
contract_test_files+=("${file#./}") contract_test_files+=("${file#./}")
continue continue
;; ;;
compose-with-preflight.sh|generate-connector-secrets-override.sh|unified-deployment-smoke.sh|vector-backup.sh|vector-restore.sh|vector-rotate-bootstrap-password.sh) compose-with-preflight.sh|generate-connector-secrets-override.sh|unified-deployment-smoke.sh|vector-rotate-bootstrap-password.sh)
continue continue
;; ;;
verify-*.sh) continue ;; verify-*.sh) continue ;;
@@ -113,7 +113,7 @@ for forbidden_file in \
done done
forbidden='omics_portal|chirone|localllm_default|datamart-builder|compose\.production\.yaml|compose\.psd-local\.yaml|\bpsd\b' forbidden='omics_portal|chirone|localllm_default|datamart-builder|compose\.production\.yaml|compose\.psd-local\.yaml|\bpsd\b'
retired_semantic='local-vector|THT_VECTOR_(BOOTSTRAP|MIGRATOR|READER|WRITER|DATABASE|HOST|PORT|USER|ADMIN_URL|OPERATOR_ENV_FILE)|THT_OLLAMA_URL|VECTOR_API_KEY_(FILE|SOURCE)|vector-api-key|(^|[^A-Za-z0-9_])THT_VEC_(REST_URL|WRITE_REST_URL)' retired_semantic='local-vector|pgvector(_direct)?|pg_(dump|restore)|THT_VECTOR_([A-Z0-9_]+)|THT_OLLAMA_URL|THT_WS_[A-Z0-9_]*_(VECTOR|EMBEDDING)_[A-Z0-9_]+|VECTOR_API_KEY_(FILE|SOURCE)|EMBEDDING_API_KEY_(FILE|SOURCE)|vector-api-key|(^|[^A-Za-z0-9_])THT_VEC_(REST_URL|WRITE_REST_URL)|thoth_vector_http'
scan_category runtime "$forbidden" "${runtime_files[@]}" scan_category runtime "$forbidden" "${runtime_files[@]}"
scan_category install "$forbidden" "${install_files[@]}" scan_category install "$forbidden" "${install_files[@]}"
scan_category operator "$forbidden" "${operator_files[@]}" scan_category operator "$forbidden" "${operator_files[@]}"
@@ -132,7 +132,7 @@ for file in "${contract_test_files[@]}"; do
esac esac
contract_scan_files+=("$file") contract_scan_files+=("$file")
done done
retired_semantic_contract='docker compose[^\n]*(compose\.local-vector|compose\.preprocess-local-vector)|THT_VECTOR_(BOOTSTRAP|MIGRATOR|READER|WRITER|DATABASE|HOST|PORT|USER|ADMIN_URL|OPERATOR_ENV_FILE)=|THT_OLLAMA_URL=|THT_WS_[A-Z0-9_]*_VECTOR_(TRANSPORT|API_KEY_(FILE|SOURCE))=|vector-api-key' retired_semantic_contract='docker compose[^\n]*(compose\.local-vector|compose\.preprocess-local-vector)|THT_VECTOR_([A-Z0-9_]+)=|THT_OLLAMA_URL=|THT_WS_[A-Z0-9_]*_(VECTOR|EMBEDDING)_[A-Z0-9_]+=|vector-api-key|pgvector|pg_(dump|restore)'
scan_category contract-test "$retired_semantic_contract" "${contract_scan_files[@]}" scan_category contract-test "$retired_semantic_contract" "${contract_scan_files[@]}"
if [[ -f scripts/run-stack.sh ]]; then if [[ -f scripts/run-stack.sh ]]; then
+87 -68
View File
@@ -6,84 +6,103 @@ tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT HUP INT TERM trap 'rm -rf "$tmp"' EXIT HUP INT TERM
fakebin="$tmp/bin" fakebin="$tmp/bin"
mkdir "$fakebin" mkdir "$fakebin"
printf '%s' secret >"$tmp/password"
chmod 0600 "$tmp/password"
cat >"$fakebin/pg_dump" <<'SH' project="thoth-task8"
volume_name="${project}_qdrant-data"
mountpoint="$tmp/docker-volumes/$volume_name/_data"
mkdir -p "$mountpoint/collections/demo"
printf '%s' before-backup >"$mountpoint/collections/demo/state.json"
cat >"$fakebin/docker" <<'SH'
#!/bin/sh #!/bin/sh
set -eu set -eu
for arg in "$@"; do case "$arg" in --file=*) output=${arg#--file=} ;; esac; done log_file=${DOCKER_LOG:?}
printf 'custom dump' >"$output" printf '%s\n' "$*" >>"$log_file"
if [ -n "${RACE_OUTPUT:-}" ]; then
printf 'concurrent owner' >"$RACE_OUTPUT"
fi
SH
chmod 0755 "$fakebin/pg_dump"
victim="$tmp/victim" if [ "$1" = volume ] && [ "$2" = ls ]; then
output="$tmp/vector.dump" if [ "${VOLUME_LS_OUTPUT:-}" = multiple ]; then
printf 'sentinel' >"$victim" printf '%s\n%s\n' "${PROJECT_NAME}_qdrant-data" "${PROJECT_NAME}_qdrant-data-copy"
ln -s "$victim" "$output.partial" exit 0
PATH="$fakebin:$PATH" ./scripts/vector-backup.sh --host source --database thoth --user admin \
--password-file "$tmp/password" --output "$output" >/dev/null
test "$(cat "$victim")" = sentinel
test "$(cat "$output")" = 'custom dump'
test -L "$output.partial"
race_output="$tmp/raced.dump"
if PATH="$fakebin:$PATH" RACE_OUTPUT="$race_output" ./scripts/vector-backup.sh \
--host source --database thoth --user admin --password-file "$tmp/password" \
--output "$race_output" >"$tmp/race.out" 2>"$tmp/race.err"; then
echo "backup replaced a destination created concurrently" >&2
exit 1
fi fi
test "$(cat "$race_output")" = 'concurrent owner' printf '%s\n' "${PROJECT_NAME}_qdrant-data"
if find "$tmp" -name '.raced.dump.tmp.*' -print | grep -q .; then exit 0
echo "backup left its owned temporary archive after publication failure" >&2
exit 1
fi fi
cat >"$fakebin/psql" <<'SH' if [ "$1" = volume ] && [ "$2" = inspect ]; then
#!/bin/sh printf '%s\n' "${MOUNTPOINT:?}"
set -eu exit 0
case "$*" in fi
*pg_control_system*)
echo same-cluster ;; if [ "$1" = compose ] && [ "$2" = --project-name ]; then
*) echo 0 ;; case "$4" in
ps)
if [ "${QDRANT_RUNNING:-1}" = 1 ]; then
printf '%s\n' qdrant-container
fi
exit 0
;;
stop)
exit 0
;;
start)
exit 0
;;
esac esac
fi
exit 0
SH SH
cat >"$fakebin/pg_restore" <<'SH' chmod 0755 "$fakebin/docker"
#!/bin/sh
printf '%s\n' "$*" >"$RESTORE_LOG" backup_output="$tmp/qdrant-backup.tar"
SH docker_log="$tmp/docker.log"
chmod 0755 "$fakebin/psql" "$fakebin/pg_restore" PATH="$fakebin:$PATH" DOCKER_LOG="$docker_log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
printf 'archive' >"$tmp/input" ./scripts/vector-backup.sh --project-name "$project" --output "$backup_output" >/dev/null
if PATH="$fakebin:$PATH" RESTORE_LOG="$tmp/restore.log" ./scripts/vector-restore.sh \ test -s "$backup_output"
--active-host source --active-database active --active-user admin \ tar -tf "$backup_output" | grep -q '^./collections/demo/state.json$'
--active-password-file "$tmp/password" --target-host target --target-database restore \ grep -q "volume ls --filter label=com.docker.compose.project=$project --filter label=com.docker.compose.volume=qdrant-data" "$docker_log"
--target-user admin --target-password-file "$tmp/password" --input "$tmp/input" \ grep -q "compose --project-name $project ps --status running -q qdrant" "$docker_log"
>"$tmp/out" 2>"$tmp/err"; then grep -q "compose --project-name $project stop qdrant" "$docker_log"
echo "restore accepted a target on the active PostgreSQL cluster" >&2 grep -q "compose --project-name $project start qdrant" "$docker_log"
existing="$tmp/existing.tar"
printf '%s' sentinel >"$existing"
if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/existing.log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
./scripts/vector-backup.sh --project-name "$project" --output "$existing" >"$tmp/existing.out" 2>"$tmp/existing.err"; then
echo "backup overwrote an existing archive" >&2
exit 1 exit 1
fi fi
grep -q 'same PostgreSQL cluster' "$tmp/err" test "$(cat "$existing")" = sentinel
test ! -e "$tmp/restore.log"
cat >"$fakebin/psql" <<'SH' if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/ambiguous.log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" VOLUME_LS_OUTPUT=multiple \
#!/bin/sh ./scripts/vector-backup.sh --project-name "$project" --output "$tmp/ambiguous.tar" >"$tmp/ambiguous.out" 2>"$tmp/ambiguous.err"; then
set -eu echo "backup accepted an ambiguous qdrant-data target" >&2
case "$*" in exit 1
*pg_control_system*) fi
case "$*" in *--host=source*) echo same-cluster ;; *) echo other-cluster ;; esac ;; grep -q 'exactly one qdrant-data volume' "$tmp/ambiguous.err"
*) echo 0 ;;
esac
SH
chmod 0755 "$fakebin/psql"
PATH="$fakebin:$PATH" RESTORE_LOG="$tmp/restore.log" ./scripts/vector-restore.sh \
--active-host source --active-database active --active-user admin \
--active-password-file "$tmp/password" --target-host target --target-database restore \
--target-user admin --target-password-file "$tmp/password" --input "$tmp/input" >/dev/null
grep -q -- '--single-transaction' "$tmp/restore.log"
grep -q -- '--exit-on-error' "$tmp/restore.log"
echo "vector backup/restore filesystem, identity, and transaction contracts passed." restore_input="$tmp/restore.tar"
restore_source="$tmp/restore-source"
mkdir -p "$restore_source/collections/demo"
printf '%s' restored >"$restore_source/collections/demo/state.json"
tar -C "$restore_source" -cf "$restore_input" .
if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/restore-refuse.log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
./scripts/vector-restore.sh --project-name "$project" --input "$restore_input" --confirm-project wrong-project \
>"$tmp/restore-refuse.out" 2>"$tmp/restore-refuse.err"; then
echo "restore skipped explicit project confirmation" >&2
exit 1
fi
grep -q 'confirmation must match --project-name exactly' "$tmp/restore-refuse.err"
test "$(cat "$mountpoint/collections/demo/state.json")" = before-backup
printf '%s' modified-live >"$mountpoint/collections/demo/state.json"
restore_log="$tmp/restore-ok.log"
PATH="$fakebin:$PATH" DOCKER_LOG="$restore_log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
./scripts/vector-restore.sh --project-name "$project" --input "$restore_input" --confirm-project "$project" >/dev/null
test "$(cat "$mountpoint/collections/demo/state.json")" = restored
grep -q "compose --project-name $project stop qdrant" "$restore_log"
grep -q "compose --project-name $project start qdrant" "$restore_log"
grep -q "volume inspect --format {{ .Mountpoint }} $volume_name" "$restore_log"
echo "qdrant backup/restore target resolution, refusal, and service-state contracts passed."
+54 -27
View File
@@ -1,53 +1,80 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
. "$root/scripts/secret-file-utils.sh"
usage() { usage() {
echo "usage: $0 --host HOST --database DB --user USER --password-file FILE --output FILE [--port PORT]" >&2 echo "usage: $0 --project-name NAME --output FILE" >&2
exit 2 exit 2
} }
host= database= user= password_file= output= port=5432 project_name=
output=
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
--host) host=${2-}; shift 2 ;; --project-name) project_name=${2-}; shift 2 ;;
--port) port=${2-}; shift 2 ;;
--database) database=${2-}; shift 2 ;;
--user) user=${2-}; shift 2 ;;
--password-file) password_file=${2-}; shift 2 ;;
--output) output=${2-}; shift 2 ;; --output) output=${2-}; shift 2 ;;
*) usage ;; *) usage ;;
esac esac
done done
[ -n "$host" ] && [ -n "$database" ] && [ -n "$user" ] || usage
[ -n "$password_file" ] && [ -n "$output" ] || usage [ -n "$project_name" ] && [ -n "$output" ] || usage
validate_secret_file "$password_file" "backup password file"
[ ! -e "$output" ] || { echo "refusing to overwrite existing backup: $output" >&2; exit 2; } [ ! -e "$output" ] || { echo "refusing to overwrite existing backup: $output" >&2; exit 2; }
output_dir=$(dirname "$output") output_dir=$(dirname "$output")
output_name=$(basename "$output") output_name=$(basename "$output")
[ -d "$output_dir" ] || { echo "backup destination directory does not exist" >&2; exit 2; } [ -d "$output_dir" ] || { echo "backup destination directory does not exist" >&2; exit 2; }
password=$(read_secret_file "$password_file" "backup password file") resolve_volume() {
names=$(docker volume ls \
--filter "label=com.docker.compose.project=$project_name" \
--filter "label=com.docker.compose.volume=qdrant-data" \
--format '{{.Name}}')
count=$(printf '%s\n' "$names" | sed '/^$/d' | wc -l | tr -d ' ')
[ "$count" -eq 1 ] || {
echo "expected exactly one qdrant-data volume for compose project $project_name" >&2
exit 2
}
printf '%s\n' "$names" | sed -n '/./{p;q;}'
}
resolve_mountpoint() {
mountpoint=$(docker volume inspect --format '{{ .Mountpoint }}' "$1")
[ -n "$mountpoint" ] || { echo "docker did not return a qdrant-data mountpoint" >&2; exit 2; }
case "$mountpoint" in
/*) ;;
*) echo "qdrant-data mountpoint is not absolute: $mountpoint" >&2; exit 2 ;;
esac
[ -d "$mountpoint" ] || { echo "qdrant-data mountpoint is not a directory: $mountpoint" >&2; exit 2; }
printf '%s\n' "$mountpoint"
}
volume_name=$(resolve_volume)
mountpoint=$(resolve_mountpoint "$volume_name")
running_container=$(docker compose --project-name "$project_name" ps --status running -q qdrant)
restart_qdrant=0
cleanup() {
status=$?
if [ "${temporary_output:-}" ] && [ -e "${temporary_output:-}" ]; then
rm -f "$temporary_output"
fi
if [ "$restart_qdrant" -eq 1 ]; then
docker compose --project-name "$project_name" start qdrant >/dev/null
fi
exit "$status"
}
trap cleanup EXIT HUP INT TERM
if [ -n "$running_container" ]; then
docker compose --project-name "$project_name" stop qdrant >/dev/null
restart_qdrant=1
fi
umask 077 umask 077
passfile=$(mktemp "${TMPDIR:-/tmp}/thoth-vector-pgpass.XXXXXX")
temporary_output=$(mktemp "$output_dir/.${output_name}.tmp.XXXXXX") temporary_output=$(mktemp "$output_dir/.${output_name}.tmp.XXXXXX")
cleanup() { rm -f "$passfile" "$temporary_output"; } tar -C "$mountpoint" -cf "$temporary_output" .
trap cleanup EXIT HUP INT TERM
escaped=$(printf '%s' "$password" | sed 's/\\/\\\\/g; s/:/\\:/g')
printf '%s:%s:%s:%s:%s\n' "$host" "$port" "$database" "$user" "$escaped" >"$passfile"
chmod 0600 "$passfile"
PGPASSFILE=$passfile pg_dump \
--host="$host" --port="$port" --username="$user" --dbname="$database" \
--format=custom --compress=9 \
--table=vectors.schema_records --table=vectors.evidence --table=vectors.memory \
--table=public.tht_vector_migrations --file="$temporary_output"
if ! ln "$temporary_output" "$output"; then if ! ln "$temporary_output" "$output"; then
echo "refusing to replace backup destination created concurrently: $output" >&2 echo "refusing to replace backup destination created concurrently: $output" >&2
exit 2 exit 2
fi fi
rm -f "$temporary_output" rm -f "$temporary_output"
echo "Vector backup written: $output" temporary_output=
echo "Qdrant backup written from $volume_name to $output"
+76 -61
View File
@@ -1,80 +1,95 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
. "$root/scripts/secret-file-utils.sh"
usage() { usage() {
echo "usage: $0 --active-host HOST --active-database DB --active-user USER --active-password-file FILE --target-host HOST --target-database DB --target-user USER --target-password-file FILE --input FILE [--active-port PORT] [--target-port PORT] [--force-nonempty]" >&2 echo "usage: $0 --project-name NAME --input FILE --confirm-project NAME" >&2
exit 2 exit 2
} }
active_host= active_database= active_user= active_password_file= active_port=5432 project_name=
target_host= target_database= target_user= target_password_file= target_port=5432 input=
input= force=0 confirm_project=
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
--active-host) active_host=${2-}; shift 2 ;; --project-name) project_name=${2-}; shift 2 ;;
--active-port) active_port=${2-}; shift 2 ;;
--active-database) active_database=${2-}; shift 2 ;;
--active-user) active_user=${2-}; shift 2 ;;
--active-password-file) active_password_file=${2-}; shift 2 ;;
--target-host) target_host=${2-}; shift 2 ;;
--target-port) target_port=${2-}; shift 2 ;;
--target-database) target_database=${2-}; shift 2 ;;
--target-user) target_user=${2-}; shift 2 ;;
--target-password-file) target_password_file=${2-}; shift 2 ;;
--input) input=${2-}; shift 2 ;; --input) input=${2-}; shift 2 ;;
--force-nonempty) force=1; shift ;; --confirm-project) confirm_project=${2-}; shift 2 ;;
*) usage ;; *) usage ;;
esac esac
done done
for value in "$active_host" "$active_database" "$active_user" "$active_password_file" \
"$target_host" "$target_database" "$target_user" "$target_password_file" "$input"; do [ -n "$project_name" ] && [ -n "$input" ] && [ -n "$confirm_project" ] || usage
[ -n "$value" ] || usage [ "$confirm_project" = "$project_name" ] || {
done echo "restore confirmation must match --project-name exactly" >&2
exit 2
}
[ -r "$input" ] || { echo "backup input is not readable" >&2; exit 2; } [ -r "$input" ] || { echo "backup input is not readable" >&2; exit 2; }
validate_secret_file "$active_password_file" "active source password file"
validate_secret_file "$target_password_file" "target password file"
umask 077 resolve_volume() {
active_pass=$(mktemp "${TMPDIR:-/tmp}/thoth-vector-active-pgpass.XXXXXX") names=$(docker volume ls \
target_pass=$(mktemp "${TMPDIR:-/tmp}/thoth-vector-target-pgpass.XXXXXX") --filter "label=com.docker.compose.project=$project_name" \
cleanup() { rm -f "$active_pass" "$target_pass"; } --filter "label=com.docker.compose.volume=qdrant-data" \
--format '{{.Name}}')
count=$(printf '%s\n' "$names" | sed '/^$/d' | wc -l | tr -d ' ')
[ "$count" -eq 1 ] || {
echo "expected exactly one qdrant-data volume for compose project $project_name" >&2
exit 2
}
printf '%s\n' "$names" | sed -n '/./{p;q;}'
}
resolve_mountpoint() {
mountpoint=$(docker volume inspect --format '{{ .Mountpoint }}' "$1")
[ -n "$mountpoint" ] || { echo "docker did not return a qdrant-data mountpoint" >&2; exit 2; }
case "$mountpoint" in
/*) ;;
*) echo "qdrant-data mountpoint is not absolute: $mountpoint" >&2; exit 2 ;;
esac
[ -d "$mountpoint" ] || { echo "qdrant-data mountpoint is not a directory: $mountpoint" >&2; exit 2; }
printf '%s\n' "$mountpoint"
}
volume_name=$(resolve_volume)
mountpoint=$(resolve_mountpoint "$volume_name")
running_container=$(docker compose --project-name "$project_name" ps --status running -q qdrant)
restart_qdrant=0
staging_dir=
extract_dir=
cleanup() {
status=$?
if [ "$status" -ne 0 ] && [ -n "${staging_dir:-}" ] && [ -d "${staging_dir:-}" ]; then
find "$mountpoint" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
find "$staging_dir" -mindepth 1 -maxdepth 1 -exec mv {} "$mountpoint"/ \;
fi
if [ -n "${staging_dir:-}" ] && [ -d "${staging_dir:-}" ]; then
rm -rf "$staging_dir"
fi
if [ -n "${extract_dir:-}" ] && [ -d "${extract_dir:-}" ]; then
rm -rf "$extract_dir"
fi
if [ "$restart_qdrant" -eq 1 ]; then
docker compose --project-name "$project_name" start qdrant >/dev/null
fi
exit "$status"
}
trap cleanup EXIT HUP INT TERM trap cleanup EXIT HUP INT TERM
make_passfile() {
secret=$(read_secret_file "$5" "database password file")
escaped=$(printf '%s' "$secret" | sed 's/\\/\\\\/g; s/:/\\:/g')
printf '%s:%s:%s:%s:%s\n' "$1" "$2" "$3" "$4" "$escaped" >"$6"
chmod 0600 "$6"
}
make_passfile "$active_host" "$active_port" "$active_database" "$active_user" \
"$active_password_file" "$active_pass"
make_passfile "$target_host" "$target_port" "$target_database" "$target_user" \
"$target_password_file" "$target_pass"
identity_sql="SELECT system_identifier::text FROM pg_control_system()" if [ -n "$running_container" ]; then
active_identity=$(PGPASSFILE=$active_pass psql -XAt --host="$active_host" --port="$active_port" \ docker compose --project-name "$project_name" stop qdrant >/dev/null
--username="$active_user" --dbname="$active_database" --command="$identity_sql") restart_qdrant=1
target_identity=$(PGPASSFILE=$target_pass psql -XAt --host="$target_host" --port="$target_port" \
--username="$target_user" --dbname="$target_database" --command="$identity_sql")
[ "$active_identity" != "$target_identity" ] || {
echo "refusing restore: active source and target are on the same PostgreSQL cluster" >&2
exit 2
}
object_count=$(PGPASSFILE=$target_pass psql -XAt --host="$target_host" --port="$target_port" \
--username="$target_user" --dbname="$target_database" --command="
SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE (n.nspname='vectors' OR (n.nspname='public' AND c.relname='tht_vector_migrations'))
AND c.relkind IN ('r','p','S','v','m');")
if [ "$object_count" != 0 ] && [ "$force" != 1 ]; then
echo "refusing restore into non-empty target; use --force-nonempty explicitly" >&2
exit 2
fi fi
PGPASSFILE=$target_pass pg_restore --exit-on-error --single-transaction \ parent_dir=$(dirname "$mountpoint")
--clean --if-exists --no-owner \ staging_dir=$(mktemp -d "$parent_dir/.qdrant-restore-staging.XXXXXX")
--host="$target_host" --port="$target_port" --username="$target_user" \ extract_dir=$(mktemp -d "${TMPDIR:-/tmp}/qdrant-restore.XXXXXX")
--dbname="$target_database" "$input" tar -C "$extract_dir" -xf "$input"
echo "Vector restore completed into explicit target $target_host:$target_port/$target_database"
find "$mountpoint" -mindepth 1 -maxdepth 1 -exec mv {} "$staging_dir"/ \;
find "$extract_dir" -mindepth 1 -maxdepth 1 -exec mv {} "$mountpoint"/ \;
rm -rf "$staging_dir"
staging_dir=
rm -rf "$extract_dir"
extract_dir=
echo "Qdrant restore completed into $volume_name for compose project $project_name"