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
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
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
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,
@@ -181,7 +181,7 @@ docker compose --env-file deploy/env/local.env \
-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
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
are bounded by configured page, object, and byte limits.
Create a versioned PostgreSQL custom-format backup (the filename is operator-controlled, so use
an immutable timestamp or release identifier):
Create a versioned Qdrant volume backup for one exact Compose project (the filename is
operator-controlled, so use an immutable timestamp or release identifier):
```sh
./scripts/vector-backup.sh \
--host 127.0.0.1 --port 5432 --database thoth --user thoth_backup \
--password-file /secure/thoth/vector-backup-password \
--output /secure/backups/thoth-vectors-2026-07-12.dump
--project-name thothii \
--output /secure/backups/thoth-qdrant-2026-08-08.tar
```
The dump contains the three allowlisted `vectors` tables, their data and ACLs, plus the
`public.tht_vector_migrations` ledger. Login roles and passwords are deliberately not copied:
provision/reconcile the approved role names on the target first, and install the `vector`
extension in its `vectors` schema. The target must otherwise contain no vector tables or ledger.
The script resolves exactly one Docker volume with the labels
`com.docker.compose.project=<project>` and `com.docker.compose.volume=qdrant-data`, stops the
`qdrant` service if it is running, archives that volume's persistent contents, then restores the
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
PostgreSQL cluster. The script compares PostgreSQL system identity, so host aliases or a different
database in the active cluster cannot bypass the guard. It refuses a non-empty target unless
`--force-nonempty` is explicit, and the clean restore is one transaction:
Restore targets that same exact project-scoped `qdrant-data` volume. Because restore replaces the
persistent Qdrant data in place, it requires an explicit confirmation that exactly repeats the
Compose project name:
```sh
./scripts/vector-restore.sh \
--active-host vector-db --active-database thoth --active-user thoth_backup \
--active-password-file /secure/thoth/vector-active-password \
--target-host vector-db-restore --target-database thoth --target-user thoth_restore \
--target-password-file /secure/thoth/vector-restore-password \
--input /secure/backups/thoth-vectors-2026-07-12.dump
--project-name thothii \
--input /secure/backups/thoth-qdrant-2026-08-08.tar \
--confirm-project thothii
```
After restore, run `tht vector migrate --status --json`, adapter health, and a known retrieval
query against the target before changing any migration/export endpoint.
The restore script stops `qdrant`, validates the exact labeled target, stages the current volume
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
+6 -1
View File
@@ -69,7 +69,12 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
const hub = deps?.hub ?? new SseHub();
const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry);
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) => (
supportsSessionRuntime(resolveRuntimeBindings(
workspace,
+230 -2
View File
@@ -16,6 +16,7 @@ import {
type WorkspaceDescriptor,
} from "./schema.js";
import type { WorkspaceErrorCode } from "./types.js";
import type { SemanticRuntimeConfig } from "./runtime-renderer.js";
export interface Diagnostic {
level: "error" | "warning" | "info";
@@ -395,6 +396,26 @@ export function createConcreteDiagnosticAdapters(
}
},
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") {
const resource = request.resource;
if (!request.host || !request.port || !request.user || !request.credentialFile
@@ -440,6 +461,21 @@ export function createConcreteDiagnosticAdapters(
};
},
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");
const response = await fetch(resolveDiagnosticUrl(request.baseUrl, request.diagnostic.path).toString(), {
method: request.diagnostic.method,
@@ -492,8 +528,31 @@ export function createConcreteDiagnosticAdapters(
export function createProductionWorkspaceDiagnoser(
timeoutMs: number,
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 {
@@ -526,6 +585,22 @@ function hasRequiredConnectorChecks(result: ConnectorDiagnosticResult, 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 {
return {
level: "error",
@@ -542,7 +617,7 @@ function diagnosticError(code: WorkspaceErrorCode, field?: string): Diagnostic {
}
function bindingName(
workspace: WorkspaceV2,
workspace: WorkspaceDescriptor,
role: "DWH" | "VECTOR" | "VECTOR_WRITER" | "EMBEDDING",
suffix: 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;
}
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(
workspace: WorkspaceV2,
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 {
return {
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 }));
});
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 () => {
const adapters = successfulAdapters({
removeDiagnosticRecord: vi.fn(() => new Promise<void>(() => undefined)),
+10 -16
View File
@@ -35,22 +35,16 @@ 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
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}
resources:
vector:
engine: qdrant
base_url: http://qdrant:6333
collection: example
embeddings:
provider: ollama_internal
base_url: http://embedding:11434
model: qwen3-embedding:0.6b
dimensions: 1024
vector:
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
+14 -20
View File
@@ -1,4 +1,4 @@
language: it
language: en
dwh:
type: thoth_rest
@@ -10,31 +10,25 @@ dwh:
api_key: ${THT_DWH_API_KEY}
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:
artifacts: /data/workspaces/psd/runtime-v2/artifacts
indexes: /data/workspaces/psd/runtime-v2/indexes
sessions: /data/workspaces/psd/sessions
artifacts: /data/workspaces/generic/artifacts
indexes: /data/workspaces/generic/indexes
sessions: /data/workspaces/generic/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
resources:
vector:
engine: qdrant
base_url: http://qdrant:6333
collection: generic
embeddings:
provider: ollama_internal
base_url: http://embedding:11434
model: qwen3-embedding:0.6b
dimensions: 1024
execution:
allow: [cte_test, explain, preview, aggregate, export]
+10 -5
View File
@@ -29,8 +29,13 @@ roots:
indexes: indexes
sessions: sessions
embeddings:
base_url: ${THT_OLLAMA_URL}
model: nomic-embed-text-v2-moe
dim: 768
batch_size: 32
resources:
vector:
engine: qdrant
base_url: http://qdrant:6333
collection: server-sessions
embeddings:
provider: ollama_internal
base_url: http://embedding:11434
model: qwen3-embedding:0.6b
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.
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
bindings, credentials, and session data are local. Never put credentials in workspace YAML, Git,
browser drafts, diagnostics, or `.env.example`.
Linux Docker Engine. It is intentionally loopback-only. Git is shared; the checkout, DWH
bindings, credentials, and session data are local, while internal Qdrant/Ollama ship in the
Compose stack. Never put credentials in workspace YAML, Git, browser drafts, diagnostics, or
`.env.example`.
## Prerequisites
@@ -60,7 +61,7 @@ and branch are non-secret; every `*_FILE` is a local path whose content never en
| 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 |
| 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 |
@@ -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.
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
its uniquely named temporary record; ordinary diagnostics are read-only.
required DWH bindings are mounted. Schema-v3 diagnostics probe the internal Qdrant/Ollama
services through backend config; ordinary diagnostics are read-only.
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
vector database/schema and the complete schema-v2 contract, then commit/push. The transformer
never imports `${ENV}` values or secrets.
`THT_SOURCE_ROOT`, transform with absolute paths, review the schema-v1 result, explicitly produce
the reviewed schema-v3 contract, then commit/push. The transformer never imports `${ENV}` values
or secrets.
```sh
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
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
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
`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.
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.
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.
## Git credentials, CA, SSH key, and known-hosts mounts
@@ -78,8 +78,8 @@ rendered Compose output.
## Shared Git values, local bindings, and secret files
Git describes workspace schema, immutable ID, DWH/vector identity, semantic-index dimensions and
distance, embedding contract, and LLM policy. The installation supplies remote/branch/installation
Git describes workspace schema, immutable ID, DWH identity, semantic-index dimensions and
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,
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
@@ -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
`THT_WS_NORTH_STAR_RESEARCH_DWH_PASSWORD_FILE`. A declared vector writer uses the distinct
`THT_WS_NORTH_STAR_RESEARCH_VECTOR_WRITER_API_KEY_FILE`; a reader file is never a writer substitute.
`THT_WS_NORTH_STAR_RESEARCH_DWH_PASSWORD_FILE`. Keep the bindings file limited to DWH transport,
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
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
@@ -216,8 +217,8 @@ For upgrades, record active status/head, finish active work, use the documented
proxy traffic.
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
identity, diagnostics, and the reviewed v2 contract before commit. Never import `${ENV}` values or
Its schema-v1 output is `migration_required`; explicitly supply collection identity, diagnostics,
and the reviewed v3 contract before commit. Never import `${ENV}` values or
copy secret files.
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 [path, profile] = process.argv.slice(2);
const config = JSON.parse(fs.readFileSync(path, "utf8"));
if (Object.keys(config.services).sort().join(",") !== "core,frontend") {
throw new Error(profile + ": install stack must be exactly core,frontend");
const expected = "core,embedding,embedding-model-init,frontend,qdrant";
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")) {
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' '// explicit descriptor migration module may mention pgvector during conversion' \
>"$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.
printf '%s\n' 'historical omics_portal and Chirone record' \
>"$fixture/repository/docs/superpowers/plans/legacy.md"
printf '%s\n' 'historical pgvector rollout note' \
>"$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' \
>"$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 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 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
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
if [[ -d deploy ]]; then
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
if [[ -d docker ]]; then
@@ -57,7 +57,7 @@ if [[ -d scripts ]]; then
contract_test_files+=("${file#./}")
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
;;
verify-*.sh) continue ;;
@@ -113,7 +113,7 @@ for forbidden_file in \
done
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 install "$forbidden" "${install_files[@]}"
scan_category operator "$forbidden" "${operator_files[@]}"
@@ -132,7 +132,7 @@ for file in "${contract_test_files[@]}"; do
esac
contract_scan_files+=("$file")
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[@]}"
if [[ -f scripts/run-stack.sh ]]; then
+84 -65
View File
@@ -6,84 +6,103 @@ tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
fakebin="$tmp/bin"
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
set -eu
for arg in "$@"; do case "$arg" in --file=*) output=${arg#--file=} ;; esac; done
printf 'custom dump' >"$output"
if [ -n "${RACE_OUTPUT:-}" ]; then
printf 'concurrent owner' >"$RACE_OUTPUT"
log_file=${DOCKER_LOG:?}
printf '%s\n' "$*" >>"$log_file"
if [ "$1" = volume ] && [ "$2" = ls ]; then
if [ "${VOLUME_LS_OUTPUT:-}" = multiple ]; then
printf '%s\n%s\n' "${PROJECT_NAME}_qdrant-data" "${PROJECT_NAME}_qdrant-data-copy"
exit 0
fi
printf '%s\n' "${PROJECT_NAME}_qdrant-data"
exit 0
fi
if [ "$1" = volume ] && [ "$2" = inspect ]; then
printf '%s\n' "${MOUNTPOINT:?}"
exit 0
fi
if [ "$1" = compose ] && [ "$2" = --project-name ]; then
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
fi
exit 0
SH
chmod 0755 "$fakebin/pg_dump"
chmod 0755 "$fakebin/docker"
victim="$tmp/victim"
output="$tmp/vector.dump"
printf 'sentinel' >"$victim"
ln -s "$victim" "$output.partial"
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"
backup_output="$tmp/qdrant-backup.tar"
docker_log="$tmp/docker.log"
PATH="$fakebin:$PATH" DOCKER_LOG="$docker_log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
./scripts/vector-backup.sh --project-name "$project" --output "$backup_output" >/dev/null
test -s "$backup_output"
tar -tf "$backup_output" | grep -q '^./collections/demo/state.json$'
grep -q "volume ls --filter label=com.docker.compose.project=$project --filter label=com.docker.compose.volume=qdrant-data" "$docker_log"
grep -q "compose --project-name $project ps --status running -q qdrant" "$docker_log"
grep -q "compose --project-name $project stop qdrant" "$docker_log"
grep -q "compose --project-name $project start qdrant" "$docker_log"
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
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
fi
test "$(cat "$race_output")" = 'concurrent owner'
if find "$tmp" -name '.raced.dump.tmp.*' -print | grep -q .; then
echo "backup left its owned temporary archive after publication failure" >&2
test "$(cat "$existing")" = sentinel
if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/ambiguous.log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" VOLUME_LS_OUTPUT=multiple \
./scripts/vector-backup.sh --project-name "$project" --output "$tmp/ambiguous.tar" >"$tmp/ambiguous.out" 2>"$tmp/ambiguous.err"; then
echo "backup accepted an ambiguous qdrant-data target" >&2
exit 1
fi
grep -q 'exactly one qdrant-data volume' "$tmp/ambiguous.err"
cat >"$fakebin/psql" <<'SH'
#!/bin/sh
set -eu
case "$*" in
*pg_control_system*)
echo same-cluster ;;
*) echo 0 ;;
esac
SH
cat >"$fakebin/pg_restore" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >"$RESTORE_LOG"
SH
chmod 0755 "$fakebin/psql" "$fakebin/pg_restore"
printf 'archive' >"$tmp/input"
if 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" \
>"$tmp/out" 2>"$tmp/err"; then
echo "restore accepted a target on the active PostgreSQL cluster" >&2
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 'same PostgreSQL cluster' "$tmp/err"
test ! -e "$tmp/restore.log"
grep -q 'confirmation must match --project-name exactly' "$tmp/restore-refuse.err"
test "$(cat "$mountpoint/collections/demo/state.json")" = before-backup
cat >"$fakebin/psql" <<'SH'
#!/bin/sh
set -eu
case "$*" in
*pg_control_system*)
case "$*" in *--host=source*) echo same-cluster ;; *) echo other-cluster ;; esac ;;
*) 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"
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 "vector backup/restore filesystem, identity, and transaction contracts passed."
echo "qdrant backup/restore target resolution, refusal, and service-state contracts passed."
+54 -27
View File
@@ -1,53 +1,80 @@
#!/bin/sh
set -eu
root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
. "$root/scripts/secret-file-utils.sh"
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
}
host= database= user= password_file= output= port=5432
project_name=
output=
while [ "$#" -gt 0 ]; do
case "$1" in
--host) host=${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 ;;
--project-name) project_name=${2-}; shift 2 ;;
--output) output=${2-}; shift 2 ;;
*) usage ;;
esac
done
[ -n "$host" ] && [ -n "$database" ] && [ -n "$user" ] || usage
[ -n "$password_file" ] && [ -n "$output" ] || usage
validate_secret_file "$password_file" "backup password file"
[ -n "$project_name" ] && [ -n "$output" ] || usage
[ ! -e "$output" ] || { echo "refusing to overwrite existing backup: $output" >&2; exit 2; }
output_dir=$(dirname "$output")
output_name=$(basename "$output")
[ -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
passfile=$(mktemp "${TMPDIR:-/tmp}/thoth-vector-pgpass.XXXXXX")
temporary_output=$(mktemp "$output_dir/.${output_name}.tmp.XXXXXX")
cleanup() { rm -f "$passfile" "$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"
tar -C "$mountpoint" -cf "$temporary_output" .
if ! ln "$temporary_output" "$output"; then
echo "refusing to replace backup destination created concurrently: $output" >&2
exit 2
fi
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
set -eu
root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
. "$root/scripts/secret-file-utils.sh"
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
}
active_host= active_database= active_user= active_password_file= active_port=5432
target_host= target_database= target_user= target_password_file= target_port=5432
input= force=0
project_name=
input=
confirm_project=
while [ "$#" -gt 0 ]; do
case "$1" in
--active-host) active_host=${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 ;;
--project-name) project_name=${2-}; shift 2 ;;
--input) input=${2-}; shift 2 ;;
--force-nonempty) force=1; shift ;;
--confirm-project) confirm_project=${2-}; shift 2 ;;
*) usage ;;
esac
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 "$value" ] || usage
done
[ -n "$project_name" ] && [ -n "$input" ] && [ -n "$confirm_project" ] || usage
[ "$confirm_project" = "$project_name" ] || {
echo "restore confirmation must match --project-name exactly" >&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
active_pass=$(mktemp "${TMPDIR:-/tmp}/thoth-vector-active-pgpass.XXXXXX")
target_pass=$(mktemp "${TMPDIR:-/tmp}/thoth-vector-target-pgpass.XXXXXX")
cleanup() { rm -f "$active_pass" "$target_pass"; }
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
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
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()"
active_identity=$(PGPASSFILE=$active_pass psql -XAt --host="$active_host" --port="$active_port" \
--username="$active_user" --dbname="$active_database" --command="$identity_sql")
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
if [ -n "$running_container" ]; then
docker compose --project-name "$project_name" stop qdrant >/dev/null
restart_qdrant=1
fi
PGPASSFILE=$target_pass pg_restore --exit-on-error --single-transaction \
--clean --if-exists --no-owner \
--host="$target_host" --port="$target_port" --username="$target_user" \
--dbname="$target_database" "$input"
echo "Vector restore completed into explicit target $target_host:$target_port/$target_database"
parent_dir=$(dirname "$mountpoint")
staging_dir=$(mktemp -d "$parent_dir/.qdrant-restore-staging.XXXXXX")
extract_dir=$(mktemp -d "${TMPDIR:-/tmp}/qdrant-restore.XXXXXX")
tar -C "$extract_dir" -xf "$input"
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"