Skip to content

Commit 4927430

Browse files
committed
feat(rag): Docker auto-setup for Qdrant and Postgres with cloud detection and health polling
1 parent 00d5650 commit 4927430

4 files changed

Lines changed: 445 additions & 0 deletions

File tree

src/rag/setup/DockerDetector.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* @fileoverview Docker environment detection and container management.
3+
* @module rag/setup/DockerDetector
4+
*
5+
* Utility class for detecting whether Docker is available, checking
6+
* container state, starting/stopping containers, and pulling images.
7+
* Used by QdrantSetup and PostgresSetup for auto-provisioning.
8+
*/
9+
10+
import { execSync } from 'node:child_process';
11+
12+
export class DockerDetector {
13+
/**
14+
* Check if Docker is installed and the daemon is running.
15+
* Runs `docker info` with a 5-second timeout.
16+
*
17+
* @returns True if Docker is available and responsive.
18+
*/
19+
static isDockerAvailable(): boolean {
20+
try {
21+
execSync('docker info', { stdio: 'pipe', timeout: 5000 });
22+
return true;
23+
} catch {
24+
return false;
25+
}
26+
}
27+
28+
/**
29+
* Check the state of a named Docker container.
30+
*
31+
* @param name - Container name to inspect.
32+
* @returns 'running' if active, 'stopped' if exists but not running,
33+
* 'not_found' if the container doesn't exist.
34+
*/
35+
static getContainerState(name: string): 'running' | 'stopped' | 'not_found' {
36+
try {
37+
const output = execSync(
38+
`docker inspect --format='{{.State.Running}}' "${name}"`,
39+
{ stdio: 'pipe', timeout: 5000 },
40+
).toString().trim();
41+
// docker inspect returns 'true' or 'false' for .State.Running
42+
return output === 'true' ? 'running' : 'stopped';
43+
} catch {
44+
// Container doesn't exist or docker not available.
45+
return 'not_found';
46+
}
47+
}
48+
49+
/**
50+
* Start a stopped container by name.
51+
*
52+
* @param name - Container name to start.
53+
* @throws If the container cannot be started.
54+
*/
55+
static startContainer(name: string): void {
56+
execSync(`docker start "${name}"`, { stdio: 'pipe', timeout: 15000 });
57+
}
58+
59+
/**
60+
* Pull a Docker image and run a new container.
61+
*
62+
* @param opts.name - Container name.
63+
* @param opts.image - Docker image (e.g. 'qdrant/qdrant:latest').
64+
* @param opts.ports - Port mappings (e.g. ['6333:6333', '6334:6334']).
65+
* @param opts.volumes - Volume mounts (e.g. ['data-vol:/data']).
66+
* @param opts.env - Environment variables (e.g. { POSTGRES_PASSWORD: 'pw' }).
67+
*/
68+
static pullAndRun(opts: {
69+
name: string;
70+
image: string;
71+
ports: string[];
72+
volumes: string[];
73+
env?: Record<string, string>;
74+
}): void {
75+
// Pull the image first (allows progress output via pipe).
76+
execSync(`docker pull "${opts.image}"`, { stdio: 'pipe', timeout: 120000 });
77+
78+
// Build the docker run command.
79+
const portFlags = opts.ports.map(p => `-p ${p}`).join(' ');
80+
const volFlags = opts.volumes.map(v => `-v ${v}`).join(' ');
81+
const envFlags = opts.env
82+
? Object.entries(opts.env).map(([k, v]) => `-e "${k}=${v}"`).join(' ')
83+
: '';
84+
85+
const cmd = [
86+
'docker run -d',
87+
`--name "${opts.name}"`,
88+
portFlags,
89+
volFlags,
90+
envFlags,
91+
`"${opts.image}"`,
92+
].filter(Boolean).join(' ');
93+
94+
execSync(cmd, { stdio: 'pipe', timeout: 30000 });
95+
}
96+
97+
/**
98+
* Poll a health check URL until it returns 200 or timeout is reached.
99+
* Checks every 500ms.
100+
*
101+
* @param url - Health check endpoint (e.g. 'http://localhost:6333/healthz').
102+
* @param timeoutMs - Maximum time to wait in milliseconds. @default 15000
103+
* @returns True if the endpoint became healthy within the timeout.
104+
*/
105+
static async waitForHealthy(url: string, timeoutMs = 15000): Promise<boolean> {
106+
const start = Date.now();
107+
while (Date.now() - start < timeoutMs) {
108+
try {
109+
const res = await fetch(url);
110+
if (res.ok) return true;
111+
} catch {
112+
// Not ready yet — keep polling.
113+
}
114+
await new Promise(r => setTimeout(r, 500));
115+
}
116+
return false;
117+
}
118+
119+
/**
120+
* Get the mapped host port for a container's internal port.
121+
* Useful when the host port was dynamically assigned.
122+
*
123+
* @param name - Container name.
124+
* @param internalPort - The container-internal port to look up.
125+
* @returns The host port number, or null if not found.
126+
*/
127+
static getHostPort(name: string, internalPort: number): number | null {
128+
try {
129+
const output = execSync(
130+
`docker port "${name}" ${internalPort}`,
131+
{ stdio: 'pipe', timeout: 5000 },
132+
).toString().trim();
133+
// Output format: "0.0.0.0:6333" or ":::6333"
134+
const parts = output.split(':');
135+
const port = parseInt(parts[parts.length - 1], 10);
136+
return isNaN(port) ? null : port;
137+
} catch {
138+
return null;
139+
}
140+
}
141+
}

src/rag/setup/PostgresSetup.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @fileoverview Postgres + pgvector auto-setup via Docker detection.
3+
* @module rag/setup/PostgresSetup
4+
*
5+
* Detection flow:
6+
* 1. Check DATABASE_URL / explicit connection string → pg_isready
7+
* 2. Check Docker → existing container → start if stopped
8+
* 3. Pull postgres:16 → run new container → install pgvector extension
9+
*/
10+
11+
import { DockerDetector } from './DockerDetector.js';
12+
import type { BackendStatus, SetupConfig } from './types.js';
13+
14+
/** Default container name for Wunderland-managed Postgres. */
15+
const CONTAINER_NAME = 'wunderland-postgres';
16+
/** Default port. */
17+
const DEFAULT_PORT = 5432;
18+
/** Default Docker image. */
19+
const IMAGE = 'postgres';
20+
/** Default password for auto-provisioned instances. */
21+
const DEFAULT_PASSWORD = 'wunderland';
22+
/** Default database name. */
23+
const DEFAULT_DB = 'agent_memory';
24+
25+
export class PostgresSetup {
26+
/**
27+
* Detect and optionally provision a Postgres + pgvector instance.
28+
*
29+
* @param config - Optional setup overrides (port, image tag, URL).
30+
* @returns Backend status with connection string.
31+
*/
32+
static async detect(config?: SetupConfig): Promise<BackendStatus> {
33+
const port = config?.port ?? DEFAULT_PORT;
34+
35+
// ── Step 1: Check explicit connection string or env var ──
36+
const connStr = config?.url ?? process.env.DATABASE_URL;
37+
if (connStr) {
38+
try {
39+
const pg = await import('pg');
40+
const client = new pg.default.Client({ connectionString: connStr });
41+
await client.connect();
42+
// Check if pgvector is installed.
43+
try {
44+
await client.query("SELECT extname FROM pg_extension WHERE extname = 'vector'");
45+
} catch {
46+
// pgvector not installed — try to install it.
47+
await client.query('CREATE EXTENSION IF NOT EXISTS vector');
48+
}
49+
await client.end();
50+
51+
// Detect cloud providers from connection string.
52+
const isCloud = /neon|supabase|rds\.amazonaws|aiven/i.test(connStr);
53+
return { status: 'running', url: connStr, source: isCloud ? 'cloud' : 'manual' };
54+
} catch (err) {
55+
return {
56+
status: 'error',
57+
url: connStr,
58+
error: `Cannot connect to Postgres: ${err instanceof Error ? err.message : String(err)}`,
59+
};
60+
}
61+
}
62+
63+
// ── Step 2: Check Docker availability ──
64+
if (!DockerDetector.isDockerAvailable()) {
65+
return {
66+
status: 'no_docker',
67+
error: 'Docker not found. Install Docker to auto-provision Postgres: https://docker.com/get-docker',
68+
};
69+
}
70+
71+
// ── Step 3: Check existing container ──
72+
const localConnStr = `postgresql://postgres:${DEFAULT_PASSWORD}@localhost:${port}/${DEFAULT_DB}`;
73+
const state = DockerDetector.getContainerState(CONTAINER_NAME);
74+
75+
if (state === 'running') {
76+
return {
77+
status: 'running',
78+
url: localConnStr,
79+
containerName: CONTAINER_NAME,
80+
source: 'docker-local',
81+
};
82+
}
83+
84+
if (state === 'stopped') {
85+
DockerDetector.startContainer(CONTAINER_NAME);
86+
// Postgres needs a few seconds to accept connections after start.
87+
await new Promise(r => setTimeout(r, 3000));
88+
return {
89+
status: 'running',
90+
url: localConnStr,
91+
containerName: CONTAINER_NAME,
92+
source: 'docker-local',
93+
};
94+
}
95+
96+
// ── Step 4: Pull and run new container ──
97+
const tag = config?.imageTag ?? '16';
98+
try {
99+
DockerDetector.pullAndRun({
100+
name: CONTAINER_NAME,
101+
image: `${IMAGE}:${tag}`,
102+
ports: [`${port}:5432`],
103+
volumes: ['wunderland-pg-data:/var/lib/postgresql/data'],
104+
env: {
105+
POSTGRES_DB: DEFAULT_DB,
106+
POSTGRES_PASSWORD: DEFAULT_PASSWORD,
107+
},
108+
});
109+
} catch (err) {
110+
return {
111+
status: 'error',
112+
error: `Failed to start Postgres container: ${err instanceof Error ? err.message : String(err)}`,
113+
};
114+
}
115+
116+
// Wait for Postgres to accept connections.
117+
await new Promise(r => setTimeout(r, 5000));
118+
119+
// ── Step 5: Install pgvector extension ──
120+
try {
121+
const pg = await import('pg');
122+
const client = new pg.default.Client({ connectionString: localConnStr });
123+
await client.connect();
124+
await client.query('CREATE EXTENSION IF NOT EXISTS vector');
125+
await client.end();
126+
} catch (err) {
127+
return {
128+
status: 'error',
129+
url: localConnStr,
130+
containerName: CONTAINER_NAME,
131+
error: `Postgres running but pgvector install failed: ${err instanceof Error ? err.message : String(err)}`,
132+
};
133+
}
134+
135+
return {
136+
status: 'running',
137+
url: localConnStr,
138+
containerName: CONTAINER_NAME,
139+
source: 'docker-local',
140+
};
141+
}
142+
}

src/rag/setup/QdrantSetup.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* @fileoverview Qdrant auto-setup via Docker detection and provisioning.
3+
* @module rag/setup/QdrantSetup
4+
*
5+
* Detection flow:
6+
* 1. Check QDRANT_URL / explicit URL → health check
7+
* 2. Check Docker → existing container → start if stopped
8+
* 3. Pull qdrant/qdrant image → run new container
9+
* 4. Poll /healthz until ready
10+
*/
11+
12+
import { DockerDetector } from './DockerDetector.js';
13+
import type { BackendStatus, SetupConfig } from './types.js';
14+
15+
/** Default container name for Wunderland-managed Qdrant. */
16+
const CONTAINER_NAME = 'wunderland-qdrant';
17+
/** Default Qdrant REST API port. */
18+
const DEFAULT_PORT = 6333;
19+
/** Default Qdrant gRPC port. */
20+
const DEFAULT_GRPC_PORT = 6334;
21+
/** Default Docker image. */
22+
const IMAGE = 'qdrant/qdrant';
23+
24+
export class QdrantSetup {
25+
/**
26+
* Detect and optionally provision a Qdrant instance.
27+
*
28+
* Priority order:
29+
* 1. Explicit URL or QDRANT_URL env var → direct health check
30+
* 2. Docker container named 'wunderland-qdrant' → start if stopped
31+
* 3. Pull and run a new Docker container
32+
*
33+
* @param config - Optional setup overrides (port, image tag, URL, API key).
34+
* @returns Backend status with URL and connection details.
35+
*/
36+
static async detect(config?: SetupConfig): Promise<BackendStatus> {
37+
const port = config?.port ?? DEFAULT_PORT;
38+
39+
// ── Step 1: Check explicit URL or env var ──
40+
const url = config?.url ?? process.env.QDRANT_URL;
41+
if (url) {
42+
try {
43+
const res = await fetch(`${url.replace(/\/+$/, '')}/healthz`);
44+
if (res.ok) {
45+
// Determine if this is a cloud instance based on URL.
46+
const isCloud = url.includes('cloud.qdrant.io') || !!config?.apiKey;
47+
return { status: 'running', url, source: isCloud ? 'cloud' : 'manual' };
48+
}
49+
} catch {
50+
return { status: 'error', url, error: `Cannot reach Qdrant at ${url}` };
51+
}
52+
}
53+
54+
// ── Step 2: Check Docker availability ──
55+
if (!DockerDetector.isDockerAvailable()) {
56+
return {
57+
status: 'no_docker',
58+
error: 'Docker not found. Install Docker to auto-provision Qdrant: https://docker.com/get-docker',
59+
};
60+
}
61+
62+
// ── Step 3: Check existing container ──
63+
const state = DockerDetector.getContainerState(CONTAINER_NAME);
64+
const localUrl = `http://localhost:${port}`;
65+
66+
if (state === 'running') {
67+
return {
68+
status: 'running',
69+
url: localUrl,
70+
containerName: CONTAINER_NAME,
71+
source: 'docker-local',
72+
};
73+
}
74+
75+
if (state === 'stopped') {
76+
DockerDetector.startContainer(CONTAINER_NAME);
77+
const healthy = await DockerDetector.waitForHealthy(`${localUrl}/healthz`);
78+
return healthy
79+
? { status: 'running', url: localUrl, containerName: CONTAINER_NAME, source: 'docker-local' }
80+
: { status: 'error', containerName: CONTAINER_NAME, error: 'Qdrant container started but health check timed out' };
81+
}
82+
83+
// ── Step 4: Pull and run new container ──
84+
const tag = config?.imageTag ?? 'latest';
85+
try {
86+
DockerDetector.pullAndRun({
87+
name: CONTAINER_NAME,
88+
image: `${IMAGE}:${tag}`,
89+
ports: [`${port}:${DEFAULT_PORT}`, `${port + 1}:${DEFAULT_GRPC_PORT}`],
90+
volumes: ['wunderland-qdrant-data:/qdrant/storage'],
91+
});
92+
} catch (err) {
93+
return {
94+
status: 'error',
95+
error: `Failed to start Qdrant container: ${err instanceof Error ? err.message : String(err)}`,
96+
};
97+
}
98+
99+
const healthy = await DockerDetector.waitForHealthy(`${localUrl}/healthz`, 20000);
100+
return healthy
101+
? { status: 'running', url: localUrl, containerName: CONTAINER_NAME, source: 'docker-local' }
102+
: { status: 'error', containerName: CONTAINER_NAME, error: 'Qdrant container created but health check timed out' };
103+
}
104+
}

0 commit comments

Comments
 (0)