fix: retire active pgvector artifacts
This commit is contained in:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -35,22 +35,16 @@ evidence:
|
|||||||
source_root: ${THT_DOCS_ROOT}
|
source_root: ${THT_DOCS_ROOT}
|
||||||
evidence_dir: evidence
|
evidence_dir: evidence
|
||||||
|
|
||||||
embeddings:
|
resources:
|
||||||
base_url: ${THT_OLLAMA_URL}
|
vector:
|
||||||
model: nomic-embed-text-v2-moe
|
engine: qdrant
|
||||||
dim: 768
|
base_url: http://qdrant:6333
|
||||||
batch_size: 32
|
collection: example
|
||||||
|
embeddings:
|
||||||
vectors:
|
provider: ollama_internal
|
||||||
type: thoth_vector_http
|
base_url: http://embedding:11434
|
||||||
reader:
|
model: qwen3-embedding:0.6b
|
||||||
base_url: ${THT_VEC_REST_URL}
|
dimensions: 1024
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|
||||||
embeddings:
|
resources:
|
||||||
base_url: ${THT_OLLAMA_URL}
|
vector:
|
||||||
model: nomic-embed-text-v2-moe
|
engine: qdrant
|
||||||
dim: 768
|
base_url: http://qdrant:6333
|
||||||
batch_size: 32
|
collection: generic
|
||||||
|
embeddings:
|
||||||
|
provider: ollama_internal
|
||||||
|
base_url: http://embedding:11434
|
||||||
|
model: qwen3-embedding:0.6b
|
||||||
|
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
|
||||||
|
|
||||||
embeddings:
|
resources:
|
||||||
base_url: ${THT_OLLAMA_URL}
|
vector:
|
||||||
model: nomic-embed-text-v2-moe
|
engine: qdrant
|
||||||
dim: 768
|
base_url: http://qdrant:6333
|
||||||
batch_size: 32
|
collection: server-sessions
|
||||||
|
embeddings:
|
||||||
|
provider: ollama_internal
|
||||||
|
base_url: http://embedding:11434
|
||||||
|
model: qwen3-embedding:0.6b
|
||||||
|
dimensions: 1024
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
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
|
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
|
SH
|
||||||
chmod 0755 "$fakebin/pg_dump"
|
chmod 0755 "$fakebin/docker"
|
||||||
|
|
||||||
victim="$tmp/victim"
|
backup_output="$tmp/qdrant-backup.tar"
|
||||||
output="$tmp/vector.dump"
|
docker_log="$tmp/docker.log"
|
||||||
printf 'sentinel' >"$victim"
|
PATH="$fakebin:$PATH" DOCKER_LOG="$docker_log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
|
||||||
ln -s "$victim" "$output.partial"
|
./scripts/vector-backup.sh --project-name "$project" --output "$backup_output" >/dev/null
|
||||||
PATH="$fakebin:$PATH" ./scripts/vector-backup.sh --host source --database thoth --user admin \
|
test -s "$backup_output"
|
||||||
--password-file "$tmp/password" --output "$output" >/dev/null
|
tar -tf "$backup_output" | grep -q '^./collections/demo/state.json$'
|
||||||
test "$(cat "$victim")" = sentinel
|
grep -q "volume ls --filter label=com.docker.compose.project=$project --filter label=com.docker.compose.volume=qdrant-data" "$docker_log"
|
||||||
test "$(cat "$output")" = 'custom dump'
|
grep -q "compose --project-name $project ps --status running -q qdrant" "$docker_log"
|
||||||
test -L "$output.partial"
|
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"
|
existing="$tmp/existing.tar"
|
||||||
if PATH="$fakebin:$PATH" RACE_OUTPUT="$race_output" ./scripts/vector-backup.sh \
|
printf '%s' sentinel >"$existing"
|
||||||
--host source --database thoth --user admin --password-file "$tmp/password" \
|
if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/existing.log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
|
||||||
--output "$race_output" >"$tmp/race.out" 2>"$tmp/race.err"; then
|
./scripts/vector-backup.sh --project-name "$project" --output "$existing" >"$tmp/existing.out" 2>"$tmp/existing.err"; then
|
||||||
echo "backup replaced a destination created concurrently" >&2
|
echo "backup overwrote an existing archive" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
test "$(cat "$race_output")" = 'concurrent owner'
|
test "$(cat "$existing")" = sentinel
|
||||||
if find "$tmp" -name '.raced.dump.tmp.*' -print | grep -q .; then
|
|
||||||
echo "backup left its owned temporary archive after publication failure" >&2
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
grep -q 'exactly one qdrant-data volume' "$tmp/ambiguous.err"
|
||||||
|
|
||||||
cat >"$fakebin/psql" <<'SH'
|
restore_input="$tmp/restore.tar"
|
||||||
#!/bin/sh
|
restore_source="$tmp/restore-source"
|
||||||
set -eu
|
mkdir -p "$restore_source/collections/demo"
|
||||||
case "$*" in
|
printf '%s' restored >"$restore_source/collections/demo/state.json"
|
||||||
*pg_control_system*)
|
tar -C "$restore_source" -cf "$restore_input" .
|
||||||
echo same-cluster ;;
|
|
||||||
*) echo 0 ;;
|
if PATH="$fakebin:$PATH" DOCKER_LOG="$tmp/restore-refuse.log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
|
||||||
esac
|
./scripts/vector-restore.sh --project-name "$project" --input "$restore_input" --confirm-project wrong-project \
|
||||||
SH
|
>"$tmp/restore-refuse.out" 2>"$tmp/restore-refuse.err"; then
|
||||||
cat >"$fakebin/pg_restore" <<'SH'
|
echo "restore skipped explicit project confirmation" >&2
|
||||||
#!/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
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
grep -q 'same PostgreSQL cluster' "$tmp/err"
|
grep -q 'confirmation must match --project-name exactly' "$tmp/restore-refuse.err"
|
||||||
test ! -e "$tmp/restore.log"
|
test "$(cat "$mountpoint/collections/demo/state.json")" = before-backup
|
||||||
|
|
||||||
cat >"$fakebin/psql" <<'SH'
|
printf '%s' modified-live >"$mountpoint/collections/demo/state.json"
|
||||||
#!/bin/sh
|
restore_log="$tmp/restore-ok.log"
|
||||||
set -eu
|
PATH="$fakebin:$PATH" DOCKER_LOG="$restore_log" PROJECT_NAME="$project" MOUNTPOINT="$mountpoint" \
|
||||||
case "$*" in
|
./scripts/vector-restore.sh --project-name "$project" --input "$restore_input" --confirm-project "$project" >/dev/null
|
||||||
*pg_control_system*)
|
test "$(cat "$mountpoint/collections/demo/state.json")" = restored
|
||||||
case "$*" in *--host=source*) echo same-cluster ;; *) echo other-cluster ;; esac ;;
|
grep -q "compose --project-name $project stop qdrant" "$restore_log"
|
||||||
*) echo 0 ;;
|
grep -q "compose --project-name $project start qdrant" "$restore_log"
|
||||||
esac
|
grep -q "volume inspect --format {{ .Mountpoint }} $volume_name" "$restore_log"
|
||||||
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."
|
echo "qdrant backup/restore target resolution, refusal, and service-state contracts passed."
|
||||||
|
|||||||
+54
-27
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user