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)
905 lines
28 KiB
TypeScript
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;
|
|
}
|
|
}
|