clawdie-ai/setup/db.ts
Operator & Codex f1dc7ea6df Drop stale jail and agent migration paths (Codex)
Remove completed controlplane agent-id migration, simplify jail-name resolution to current canonical names, and drop SUDO_UID ownership fallback from service setup.

---
Build: pass | Tests: pass — 2370 passed (704 files)
2026-05-10 21:30:17 +02:00

905 lines
28 KiB
TypeScript

/**
* setup/db.ts — Provision PostgreSQL for Clawdie (skills + memory).
*
* Supports two modes:
* - `DB_RUNTIME=host` (default): provision PostgreSQL directly on the host;
* jails connect via warden0 at `${AGENT_SUBNET_BASE}.1:5432`
* - `DB_RUNTIME=jail`: provision PostgreSQL in a Bastille db jail when an
* operator intentionally wants database isolation from the host service)
*/
import { execSync, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
DB_INTERNAL_DOMAIN,
DB_HOST,
DB_RUNTIME,
GIT_JAIL_IP,
SUBNET_BASE,
CMS_JAIL_IP,
TENANT_ID,
} from '../src/config.js';
import { buildHostDbDatasets } from '../src/maintenance-snapshots.js';
import {
bastille,
jailExists,
detectFreeBSDRelease,
jailRoot,
resolveJailName,
} from './bastille-helpers.js';
import { readEnvFile } from '../src/env.js';
import { logger } from '../src/logger.js';
import { ensureSplitBrainSecrets } from './secrets.js';
import { loadPackageList, mountPkgCacheInJail } from './packages.js';
import { commandExists, getPlatform, isRoot } from './platform.js';
import { emitStatus } from './status.js';
import { maybeEnableTailscaleInJail } from './tailscale.js';
const LOG = 'logs/setup.log';
function execOrThrow(cmd: string, args: string[], errorHint: string): void {
const res = spawnSync(cmd, args, { encoding: 'utf-8' });
if ((res.status ?? 1) !== 0) {
throw new Error(
`${errorHint}: ${res.stderr || res.stdout || 'unknown error'}`,
);
}
}
function datasetExists(name: string): boolean {
try {
execSync(`zfs list -H -o name ${name}`, {
stdio: ['ignore', 'ignore', 'ignore'],
});
return true;
} catch {
return false;
}
}
function createZfsDataset(
name: string,
mountpoint: string,
props: Record<string, string>,
): void {
if (!datasetExists(name)) {
execOrThrow(
'zfs',
['create', '-p', '-o', `mountpoint=${mountpoint}`, name],
'zfs create failed',
);
}
for (const [key, value] of Object.entries(props)) {
execOrThrow(
'zfs',
['set', `${key}=${value}`, name],
`zfs set ${key} failed`,
);
}
}
function resolveCompression(raw: string | undefined): string {
const value = (raw || '').trim().toLowerCase();
if (value === 'zstd') return 'zstd';
return 'lz4';
}
function runHostPsql(sql: string, database = 'postgres'): void {
const projectRoot = process.cwd();
const tmpDir = path.join(projectRoot, 'tmp');
fs.mkdirSync(tmpDir, { recursive: true });
const stamp = Date.now();
const sqlFile = path.join(tmpDir, `host-db-sql-${stamp}.sql`);
fs.writeFileSync(sqlFile, sql);
try {
// Use -f to avoid shell expansion issues (e.g. DO $$ ... $$ blocks).
const cmd = `psql -v ON_ERROR_STOP=1 -d ${JSON.stringify(database)} -f ${JSON.stringify(sqlFile)}`;
const res = spawnSync('su', ['-m', 'postgres', '-c', cmd], {
encoding: 'utf-8',
});
if ((res.status ?? 1) !== 0) {
throw new Error(
`psql failed: ${res.stderr || res.stdout || 'unknown error'}`,
);
}
} finally {
fs.rmSync(sqlFile, { force: true });
}
}
function runHostPsqlFile(filePath: string, database: string): void {
const projectRoot = process.cwd();
const tmpDir = path.join(projectRoot, 'tmp');
fs.mkdirSync(tmpDir, { recursive: true });
// The repo may not be world-traversable (e.g. docs/ is 770). Copy the schema
// file to a tmp location that the postgres user can read before executing.
const stamp = Date.now();
const tmpSchema = path.join(tmpDir, `host-db-schema-${stamp}.sql`);
fs.copyFileSync(filePath, tmpSchema);
try {
const cmd = `psql -v ON_ERROR_STOP=1 -d ${JSON.stringify(database)} -f ${JSON.stringify(tmpSchema)}`;
const res = spawnSync('su', ['-m', 'postgres', '-c', cmd], {
encoding: 'utf-8',
});
if ((res.status ?? 1) !== 0) {
throw new Error(
`psql -f failed: ${res.stderr || res.stdout || 'unknown error'}`,
);
}
} finally {
fs.rmSync(tmpSchema, { force: true });
}
}
function runHostPsqlQuery(query: string): string[] {
const cmd = `psql -tAc ${JSON.stringify(query)}`;
const res = spawnSync('su', ['-m', 'postgres', '-c', cmd], {
encoding: 'utf-8',
});
if ((res.status ?? 1) !== 0) {
throw new Error(
`psql query failed: ${res.stderr || res.stdout || 'unknown error'}`,
);
}
return (res.stdout || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function ensureHostDatabase(database: string, owner: string): void {
const rows = runHostPsqlQuery(
`SELECT 1 FROM pg_database WHERE datname='${database}'`,
);
if (rows.includes('1')) return;
runHostPsql(`CREATE DATABASE ${database} OWNER ${owner};`);
}
function ensureLine(filePath: string, line: string): void {
const content = fs.existsSync(filePath)
? fs.readFileSync(filePath, 'utf-8')
: '';
if (!content.includes(line)) {
fs.appendFileSync(
filePath,
(content.endsWith('\n') || !content ? '' : '\n') + line + '\n',
);
}
}
function setOrAppendConfLine(
filePath: string,
key: string,
value: string,
): void {
const content = fs.existsSync(filePath)
? fs.readFileSync(filePath, 'utf-8')
: '';
const re = new RegExp(`^#?\\s*${key}\\s*=.*$`, 'm');
const nextLine = `${key} = '${value}'`;
const next = re.test(content)
? content.replace(re, nextLine)
: `${content.trimEnd()}\n${nextLine}\n`;
fs.writeFileSync(filePath, next);
}
function runPsql(jailName: string, sql: string, database = 'postgres'): void {
const projectRoot = process.cwd();
const tmpDir = path.join(projectRoot, 'tmp');
fs.mkdirSync(tmpDir, { recursive: true });
const stamp = Date.now();
const hostTmp = path.join(tmpDir, `db-sql-${stamp}.sql`);
fs.writeFileSync(hostTmp, sql);
const jailTmpRel = path.join('var', 'tmp', `db-sql-${stamp}.sql`);
const jailTmpHostPath = path.join(jailRoot(jailName), jailTmpRel);
fs.mkdirSync(path.dirname(jailTmpHostPath), { recursive: true });
fs.copyFileSync(hostTmp, jailTmpHostPath);
try {
runPsqlFile(jailName, database, `/${jailTmpRel}`);
} finally {
fs.rmSync(hostTmp, { force: true });
fs.rmSync(jailTmpHostPath, { force: true });
}
}
function runPsqlFile(
jailName: string,
database: string,
jailFilePath: string,
): void {
const res = bastille(
'cmd',
jailName,
'su',
'-m',
'postgres',
'-c',
`psql -v ON_ERROR_STOP=1 -d ${JSON.stringify(database)} -f ${JSON.stringify(jailFilePath)}`,
);
if (!res.ok) {
throw new Error(`psql -f failed: ${res.output}`);
}
}
function ensureDatabase(
jailName: string,
database: string,
owner: string,
): void {
const query = `SELECT 1 FROM pg_database WHERE datname='${database}'`;
const check = bastille(
'cmd',
jailName,
'su',
'-m',
'postgres',
'-c',
`psql -tAc ${JSON.stringify(query)}`,
);
if (!check.ok) {
throw new Error(`psql failed: ${check.output}`);
}
const lines = check.output
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('['));
if (lines.includes('1')) return;
runPsql(jailName, `CREATE DATABASE ${database} OWNER ${owner};`);
}
function provisionHostDb(options: {
projectRoot: string;
dbHost: string;
dbCompression: string;
secrets: ReturnType<typeof ensureSplitBrainSecrets>;
}): void {
const { data: dataDataset, wal: walDataset } = buildHostDbDatasets();
const dataDir = '/var/db/postgres/data';
const walDir = '/var/db/postgres/wal';
createZfsDataset(dataDataset, dataDir, {
recordsize: '16K',
atime: 'off',
compression: options.dbCompression,
});
createZfsDataset(walDataset, walDir, {
recordsize: '16K',
atime: 'off',
compression: 'off',
logbias: 'throughput',
});
execOrThrow(
'pkg',
['install', '-y', ...loadPackageList('db-jail.txt')],
'pkg install failed',
);
execOrThrow(
'sysrc',
['postgresql_enable=YES'],
'sysrc postgresql_enable failed',
);
execOrThrow(
'sysrc',
[`postgresql_data=${dataDir}`],
'sysrc postgresql_data failed',
);
execOrThrow(
'chown',
['-R', 'postgres:postgres', dataDir, walDir],
'chown postgres failed',
);
const pgVersion = path.join(dataDir, 'PG_VERSION');
if (!fs.existsSync(pgVersion)) {
const initCmd = `/usr/local/bin/initdb -D ${dataDir} --waldir=${walDir}`;
const init = spawnSync('su', ['-m', 'postgres', '-c', initCmd], {
encoding: 'utf-8',
});
if ((init.status ?? 1) !== 0) {
throw new Error(
`postgresql initdb failed: ${init.stderr || init.stdout || 'unknown error'}`,
);
}
}
const postgresConf = path.join(dataDir, 'postgresql.conf');
const pgHbaConf = path.join(dataDir, 'pg_hba.conf');
const listenHosts = Array.from(
new Set(['127.0.0.1', options.dbHost].filter(Boolean)),
).join(',');
if (fs.existsSync(postgresConf)) {
setOrAppendConfLine(postgresConf, 'listen_addresses', listenHosts);
}
if (fs.existsSync(pgHbaConf)) {
const gateway = `${SUBNET_BASE}.1`;
ensureLine(
pgHbaConf,
`host ${options.secrets.skillsDbName} ${options.secrets.skillsDbUser} ${gateway}/32 scram-sha-256`,
);
ensureLine(
pgHbaConf,
`host ${options.secrets.memoryDbName} ${options.secrets.memoryDbUser} ${gateway}/32 scram-sha-256`,
);
ensureLine(
pgHbaConf,
`host ${options.secrets.opsDbName} ${options.secrets.opsDbUser} ${gateway}/32 scram-sha-256`,
);
ensureLine(
pgHbaConf,
`host ${options.secrets.forgejoDbName} ${options.secrets.forgejoDbUser} ${GIT_JAIL_IP}/32 scram-sha-256`,
);
ensureLine(
pgHbaConf,
`host strapi_cms strapi_cms ${CMS_JAIL_IP}/32 scram-sha-256`,
);
// Worker jails (controlplane agents) — allow subnet-wide access
const jailSubnet = `${SUBNET_BASE}.0/24`;
ensureLine(
pgHbaConf,
`host ${options.secrets.memoryDbName} ${options.secrets.memoryDbUser} ${jailSubnet} scram-sha-256`,
);
ensureLine(
pgHbaConf,
`host ${options.secrets.opsDbName} ${options.secrets.opsDbUser} ${jailSubnet} scram-sha-256`,
);
ensureLine(
pgHbaConf,
`host ${options.secrets.skillsDbName} ${options.secrets.skillsDbUser} ${jailSubnet} scram-sha-256`,
);
}
const status = spawnSync('service', ['postgresql', 'onestatus'], {
encoding: 'utf-8',
});
const action = (status.status ?? 1) === 0 ? 'restart' : 'start';
const start = spawnSync('service', ['postgresql', action], {
encoding: 'utf-8',
});
if ((start.status ?? 1) !== 0) {
throw new Error(
`postgresql start failed: ${start.stderr || start.stdout || 'unknown error'}`,
);
}
runHostPsql(
`ALTER USER postgres WITH PASSWORD '${options.secrets.postgresAdminPassword}';`,
);
runHostPsql(
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${options.secrets.skillsDbUser}') THEN
CREATE ROLE ${options.secrets.skillsDbUser} WITH LOGIN PASSWORD '${options.secrets.skillsDbPassword}';
END IF;
END $$;`,
);
runHostPsql(
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${options.secrets.memoryDbUser}') THEN
CREATE ROLE ${options.secrets.memoryDbUser} WITH LOGIN PASSWORD '${options.secrets.memoryDbPassword}';
END IF;
END $$;`,
);
runHostPsql(
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${options.secrets.forgejoDbUser}') THEN
CREATE ROLE ${options.secrets.forgejoDbUser} WITH LOGIN PASSWORD '${options.secrets.forgejoDbPassword}';
END IF;
END $$;`,
);
runHostPsql(
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${options.secrets.opsDbUser}') THEN
CREATE ROLE ${options.secrets.opsDbUser} WITH LOGIN PASSWORD '${options.secrets.opsDbPassword}';
END IF;
END $$;`,
);
runHostPsql(
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'strapi_cms') THEN
CREATE ROLE strapi_cms WITH LOGIN PASSWORD '${options.secrets.strapiDbPassword}';
END IF;
END $$;`,
);
ensureHostDatabase(
options.secrets.skillsDbName,
options.secrets.skillsDbUser,
);
ensureHostDatabase(
options.secrets.memoryDbName,
options.secrets.memoryDbUser,
);
ensureHostDatabase(
options.secrets.forgejoDbName,
options.secrets.forgejoDbUser,
);
ensureHostDatabase(options.secrets.opsDbName, options.secrets.opsDbUser);
ensureHostDatabase('strapi_cms', 'strapi_cms');
for (const db of [
options.secrets.skillsDbName,
options.secrets.memoryDbName,
]) {
runHostPsql('CREATE EXTENSION IF NOT EXISTS pgcrypto;', db);
runHostPsql('CREATE EXTENSION IF NOT EXISTS vector;', db);
runHostPsql('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";', db);
}
runHostPsql(
'CREATE EXTENSION IF NOT EXISTS pgcrypto;',
options.secrets.opsDbName,
);
const schemas: Array<{ src: string; db: string }> = [
{
src: path.join(
options.projectRoot,
'docs',
'internal',
'sql',
'builtin-knowledge-base.sql',
),
db: options.secrets.skillsDbName,
},
{
src: path.join(
options.projectRoot,
'docs',
'internal',
'sql',
'ai-brain-base.sql',
),
db: options.secrets.memoryDbName,
},
{
src: path.join(
options.projectRoot,
'docs',
'internal',
'sql',
'brain-hybrid-upgrade.sql',
),
db: options.secrets.memoryDbName,
},
{
src: path.join(
options.projectRoot,
'docs',
'internal',
'sql',
'search-memories.sql',
),
db: options.secrets.memoryDbName,
},
];
for (const item of schemas) {
if (!fs.existsSync(item.src)) {
throw new Error(`missing_schema_file: ${item.src}`);
}
runHostPsqlFile(item.src, item.db);
}
runHostPsql(
[
`GRANT USAGE ON SCHEMA public TO ${options.secrets.skillsDbUser};`,
`GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${options.secrets.skillsDbUser};`,
`GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO ${options.secrets.skillsDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${options.secrets.skillsDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO ${options.secrets.skillsDbUser};`,
].join(' '),
options.secrets.skillsDbName,
);
runHostPsql(
[
`GRANT USAGE ON SCHEMA public TO ${options.secrets.memoryDbUser};`,
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${options.secrets.memoryDbUser};`,
`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${options.secrets.memoryDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${options.secrets.memoryDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${options.secrets.memoryDbUser};`,
].join(' '),
options.secrets.memoryDbName,
);
runHostPsql(
[
`GRANT USAGE ON SCHEMA public TO ${options.secrets.opsDbUser};`,
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${options.secrets.opsDbUser};`,
`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${options.secrets.opsDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${options.secrets.opsDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${options.secrets.opsDbUser};`,
].join(' '),
options.secrets.opsDbName,
);
}
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
const envOverrides = readEnvFile([
'DB_JAIL_NAME',
'DB_RUNTIME',
'DB_HOST',
'DB_COMPRESSION',
]);
const runtime = (
process.env.DB_RUNTIME ||
envOverrides.DB_RUNTIME ||
DB_RUNTIME ||
'jail'
)
.trim()
.toLowerCase();
const dbCompression = resolveCompression(
process.env.DB_COMPRESSION || envOverrides.DB_COMPRESSION,
);
if (getPlatform() !== 'freebsd') {
emitStatus('SETUP_DB', {
STATUS: 'failed',
ERROR: 'unsupported_platform',
LOG,
});
process.exit(1);
}
if (!isRoot()) {
emitStatus('SETUP_DB', { STATUS: 'failed', ERROR: 'requires_root', LOG });
throw new Error('setup_db_requires_root');
}
const explicitJailName = (
process.env.DB_JAIL_NAME ||
envOverrides.DB_JAIL_NAME ||
''
).trim();
const jailName = resolveJailName({
role: 'db',
envOverride: explicitJailName || undefined,
});
const dbIp = process.env.WARDEN_DB_IP || DB_HOST || `${SUBNET_BASE}.5`;
const hostname = DB_INTERNAL_DOMAIN;
const gateway = process.env.WARDEN_GATEWAY || `${SUBNET_BASE}.1`;
const bridge = process.env.WARDEN_BRIDGE || 'warden0';
const release = detectFreeBSDRelease();
// Ensure secrets + derived DB identifiers exist in .env
const secrets = ensureSplitBrainSecrets(projectRoot);
const postgresConf = path.join(
jailRoot(jailName),
'var/db/postgres/data/postgresql.conf',
);
const pgHbaConf = path.join(
jailRoot(jailName),
'var/db/postgres/data/pg_hba.conf',
);
const runBastille = (args: string[]) => bastille(...args);
try {
if (runtime === 'host') {
provisionHostDb({
projectRoot,
dbHost: DB_HOST,
dbCompression,
secrets,
});
emitStatus('SETUP_DB', {
STATUS: 'success',
DB_RUNTIME: 'host',
DB_HOST,
SKILLS_DB: secrets.skillsDbName,
MEMORY_DB: secrets.memoryDbName,
OPS_DB: secrets.opsDbName,
LOG,
});
return;
}
if (!commandExists('bastille')) {
emitStatus('SETUP_DB', {
STATUS: 'failed',
ERROR: 'missing_bastille',
LOG,
});
throw new Error('missing_bastille');
}
// ── Create jail ─────────────────────────────────────────────────────────
const exists = jailExists(jailName);
if (!exists) {
logger.info({ jailName, ip: dbIp, release }, 'Creating db jail');
const create = bastille(
'create',
'-T',
'-B',
'-g',
gateway,
jailName,
release,
`${dbIp}/24`,
bridge,
);
if (!create.ok) {
throw new Error(`bastille create failed: ${create.output}`);
}
bastille('config', jailName, 'set', 'host.hostname', hostname);
bastille('config', jailName, 'set', 'allow.sysvipc', '1');
bastille('restart', jailName);
} else {
logger.info({ jailName }, 'db jail already exists, skipping creation');
}
if (exists) {
bastille('config', jailName, 'set', 'allow.sysvipc', '1');
bastille('restart', jailName);
}
// ── Packages ────────────────────────────────────────────────────────────
mountPkgCacheInJail(jailName);
const packages = loadPackageList('db-jail.txt');
const pkg = bastille('pkg', jailName, 'install', '-y', ...packages);
if (!pkg.ok) {
logger.warn(
{ output: pkg.output },
'db jail package install had warnings',
);
}
maybeEnableTailscaleInJail(runBastille, jailName, jailName);
// ── Postgres init + start ───────────────────────────────────────────────
bastille('cmd', jailName, 'sysrc', 'postgresql_enable=YES');
bastille('cmd', jailName, 'sysrc', 'postgresql_data=/var/db/postgres/data');
const dataDir = path.join(jailRoot(jailName), 'var/db/postgres/data');
const pgVersion = path.join(dataDir, 'PG_VERSION');
if (!fs.existsSync(pgVersion)) {
logger.info({ jailName }, 'Initializing PostgreSQL data directory');
const init = bastille('cmd', jailName, 'service', 'postgresql', 'initdb');
if (!init.ok) throw new Error(`postgresql initdb failed: ${init.output}`);
}
// Ensure the db listens on loopback + jail IP for host access.
if (fs.existsSync(postgresConf)) {
setOrAppendConfLine(
postgresConf,
'listen_addresses',
`127.0.0.1,${dbIp}`,
);
}
// Allow host (warden gateway) to connect to both DBs.
// Keep this minimal: only these users/databases, scram-sha-256.
if (fs.existsSync(pgHbaConf)) {
ensureLine(
pgHbaConf,
`host ${secrets.skillsDbName} ${secrets.skillsDbUser} ${gateway}/32 scram-sha-256`,
);
ensureLine(
pgHbaConf,
`host ${secrets.memoryDbName} ${secrets.memoryDbUser} ${gateway}/32 scram-sha-256`,
);
ensureLine(
pgHbaConf,
`host ${secrets.forgejoDbName} ${secrets.forgejoDbUser} ${GIT_JAIL_IP}/32 scram-sha-256`,
);
ensureLine(
pgHbaConf,
`host ${secrets.opsDbName} ${secrets.opsDbUser} ${gateway}/32 scram-sha-256`,
);
ensureLine(
pgHbaConf,
`host strapi_cms strapi_cms ${process.env.WARDEN_CMS_IP || `${SUBNET_BASE}.3`}/32 scram-sha-256`,
);
}
const status = bastille(
'cmd',
jailName,
'service',
'postgresql',
'onestatus',
);
const action = status.ok ? 'restart' : 'start';
const start = bastille('cmd', jailName, 'service', 'postgresql', action);
if (!start.ok) throw new Error(`postgresql start failed: ${start.output}`);
// ── Roles + DBs ─────────────────────────────────────────────────────────
runPsql(
jailName,
`ALTER USER postgres WITH PASSWORD '${secrets.postgresAdminPassword}';`,
);
runPsql(
jailName,
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${secrets.skillsDbUser}') THEN
CREATE ROLE ${secrets.skillsDbUser} WITH LOGIN PASSWORD '${secrets.skillsDbPassword}';
END IF;
END $$;`,
);
runPsql(
jailName,
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${secrets.memoryDbUser}') THEN
CREATE ROLE ${secrets.memoryDbUser} WITH LOGIN PASSWORD '${secrets.memoryDbPassword}';
END IF;
END $$;`,
);
runPsql(
jailName,
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${secrets.forgejoDbUser}') THEN
CREATE ROLE ${secrets.forgejoDbUser} WITH LOGIN PASSWORD '${secrets.forgejoDbPassword}';
END IF;
END $$;`,
);
runPsql(
jailName,
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${secrets.opsDbUser}') THEN
CREATE ROLE ${secrets.opsDbUser} WITH LOGIN PASSWORD '${secrets.opsDbPassword}';
END IF;
END $$;`,
);
runPsql(
jailName,
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'strapi_cms') THEN
CREATE ROLE strapi_cms WITH LOGIN PASSWORD '${secrets.strapiDbPassword}';
END IF;
END $$;`,
);
ensureDatabase(jailName, secrets.skillsDbName, secrets.skillsDbUser);
ensureDatabase(jailName, secrets.memoryDbName, secrets.memoryDbUser);
ensureDatabase(jailName, secrets.forgejoDbName, secrets.forgejoDbUser);
ensureDatabase(jailName, secrets.opsDbName, secrets.opsDbUser);
ensureDatabase(jailName, 'strapi_cms', 'strapi_cms');
// ── Extensions ──────────────────────────────────────────────────────────
for (const db of [secrets.skillsDbName, secrets.memoryDbName]) {
runPsql(jailName, 'CREATE EXTENSION IF NOT EXISTS pgcrypto;', db);
runPsql(jailName, 'CREATE EXTENSION IF NOT EXISTS vector;', db);
runPsql(jailName, 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";', db);
}
runPsql(
jailName,
'CREATE EXTENSION IF NOT EXISTS pgcrypto;',
secrets.opsDbName,
);
// ── Schema SQL ──────────────────────────────────────────────────────────
const sqlDirInJail = '/var/db/postgres/clawdie-sql';
bastille(
'cmd',
jailName,
'install',
'-d',
'-m',
'755',
'-o',
'postgres',
'-g',
'postgres',
sqlDirInJail,
);
const schemas: Array<{ src: string; dest: string; db: string }> = [
{
src: path.join(
projectRoot,
'docs',
'internal',
'sql',
'builtin-knowledge-base.sql',
),
dest: `${sqlDirInJail}/builtin-knowledge-base.sql`,
db: secrets.skillsDbName,
},
{
src: path.join(
projectRoot,
'docs',
'internal',
'sql',
'ai-brain-base.sql',
),
dest: `${sqlDirInJail}/ai-brain-base.sql`,
db: secrets.memoryDbName,
},
{
src: path.join(
projectRoot,
'docs',
'internal',
'sql',
'brain-hybrid-upgrade.sql',
),
dest: `${sqlDirInJail}/brain-hybrid-upgrade.sql`,
db: secrets.memoryDbName,
},
{
src: path.join(
projectRoot,
'docs',
'internal',
'sql',
'search-memories.sql',
),
dest: `${sqlDirInJail}/search-memories.sql`,
db: secrets.memoryDbName,
},
];
for (const item of schemas) {
if (!fs.existsSync(item.src)) {
throw new Error(`missing_schema_file: ${item.src}`);
}
const hostDest = path.join(
jailRoot(jailName),
item.dest.replace(/^\//, ''),
);
fs.mkdirSync(path.dirname(hostDest), { recursive: true });
fs.copyFileSync(item.src, hostDest);
runPsqlFile(jailName, item.db, item.dest);
}
runPsql(
jailName,
[
`GRANT USAGE ON SCHEMA public TO ${secrets.skillsDbUser};`,
`GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${secrets.skillsDbUser};`,
`GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO ${secrets.skillsDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${secrets.skillsDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO ${secrets.skillsDbUser};`,
].join(' '),
secrets.skillsDbName,
);
runPsql(
jailName,
[
`GRANT USAGE ON SCHEMA public TO ${secrets.memoryDbUser};`,
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${secrets.memoryDbUser};`,
`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${secrets.memoryDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${secrets.memoryDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${secrets.memoryDbUser};`,
].join(' '),
secrets.memoryDbName,
);
runPsql(
jailName,
[
`GRANT USAGE ON SCHEMA public TO ${secrets.opsDbUser};`,
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${secrets.opsDbUser};`,
`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${secrets.opsDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${secrets.opsDbUser};`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${secrets.opsDbUser};`,
].join(' '),
secrets.opsDbName,
);
emitStatus('SETUP_DB', {
STATUS: 'success',
JAIL_NAME: jailName,
JAIL_IP: dbIp,
SKILLS_DB: secrets.skillsDbName,
MEMORY_DB: secrets.memoryDbName,
OPS_DB: secrets.opsDbName,
LOG,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
emitStatus('SETUP_DB', { STATUS: 'failed', ERROR: message, LOG });
throw err;
}
}