604 lines
27 KiB
JavaScript
604 lines
27 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
export const DEFAULT_RECIPES_ROOT = join(REPO_ROOT, 'deploy', 'database');
|
|
const DEFAULT_MAKEFILE_PATH = join(REPO_ROOT, 'Makefile');
|
|
const DEFAULT_PASSWORD = '123456';
|
|
const DEFAULT_DATABASE = 'dbx';
|
|
const DEFAULT_HOST_PORT_RANGES = {
|
|
mysql: [10100, 10199],
|
|
mariadb: [10200, 10299],
|
|
postgresql: [10300, 10399],
|
|
mongodb: [10400, 10499],
|
|
redis: [10500, 10599],
|
|
clickhouse: [10600, 10699],
|
|
etcd: [10700, 10799],
|
|
zookeeper: [10800, 10899],
|
|
consul: [10900, 10999],
|
|
nacos: [11000, 11099],
|
|
rnacos: [11100, 11199],
|
|
qdrant: [11200, 11299],
|
|
kafka: [11300, 11399],
|
|
pulsar: [11400, 11499],
|
|
elasticsearch: [11500, 11599],
|
|
};
|
|
const DBX_DEEP_LINK_TYPES = {
|
|
clickhouse: 'clickhouse',
|
|
consul: 'consul',
|
|
elasticsearch: 'elasticsearch',
|
|
etcd: 'etcd',
|
|
mariadb: 'mariadb',
|
|
mongodb: 'mongodb',
|
|
mysql: 'mysql',
|
|
postgresql: 'postgres',
|
|
qdrant: 'qdrant',
|
|
redis: 'redis',
|
|
rnacos: 'r-nacos',
|
|
zookeeper: 'zookeeper',
|
|
};
|
|
|
|
export function discoverRecipes(root = DEFAULT_RECIPES_ROOT) {
|
|
if (!existsSync(root)) return [];
|
|
const recipes = [];
|
|
for (const database of readdirSync(root, { withFileTypes: true })) {
|
|
if (!database.isDirectory()) continue;
|
|
const databaseDir = join(root, database.name);
|
|
for (const version of readdirSync(databaseDir, { withFileTypes: true })) {
|
|
if (!version.isDirectory()) continue;
|
|
const recipePath = join(databaseDir, version.name, 'recipe.json');
|
|
if (!existsSync(recipePath)) continue;
|
|
const recipe = JSON.parse(readFileSync(recipePath, 'utf8'));
|
|
recipes.push({ ...recipe, directory: dirname(recipePath), recipePath });
|
|
}
|
|
}
|
|
return recipes.sort((a, b) => `${a.database}/${a.displayVersion}`.localeCompare(`${b.database}/${b.displayVersion}`, 'en'));
|
|
}
|
|
|
|
export function recipeSelector(recipe) {
|
|
return `${recipe.database}@${recipe.displayVersion}`;
|
|
}
|
|
|
|
export function formatTable(headers, rows) {
|
|
const widths = headers.map((header, index) => Math.max(
|
|
header.length,
|
|
...rows.map((row) => String(row[index] ?? '').length),
|
|
));
|
|
const renderRow = (row) => row.map((value, index) => {
|
|
const text = String(value ?? '');
|
|
return index === row.length - 1 ? text : text.padEnd(widths[index]);
|
|
}).join(' ');
|
|
|
|
return [headers, ...rows].map(renderRow).join('\n');
|
|
}
|
|
|
|
export function formatBorderedTable(headers, rows, groupByFirstColumn = false) {
|
|
const widths = headers.map((header, index) => Math.max(
|
|
header.length,
|
|
...rows.map((row) => String(row[index] ?? '').length),
|
|
));
|
|
const border = `+${widths.map((width) => '-'.repeat(width + 2)).join('+')}+`;
|
|
const renderRow = (row) => `| ${row.map((value, index) => String(value ?? '').padEnd(widths[index])).join(' | ')} |`;
|
|
const body = [];
|
|
for (const [index, row] of rows.entries()) {
|
|
body.push(renderRow(row));
|
|
if (groupByFirstColumn && index < rows.length - 1 && rows[index + 1][0] !== '') body.push(border);
|
|
}
|
|
|
|
return [border, renderRow(headers), border, ...body, border].join('\n');
|
|
}
|
|
|
|
export function discoverMakeTargets(makefilePath = DEFAULT_MAKEFILE_PATH) {
|
|
if (!existsSync(makefilePath)) return [];
|
|
const source = readFileSync(makefilePath, 'utf8').replace(/\\\r?\n/g, ' ');
|
|
const targets = [];
|
|
for (const line of source.split(/\r?\n/)) {
|
|
const match = line.match(/^\.PHONY:\s*(.+)$/);
|
|
if (match) targets.push(...match[1].trim().split(/\s+/));
|
|
}
|
|
return [...new Set(targets)];
|
|
}
|
|
|
|
export function platformForArchitecture(architecture = process.arch) {
|
|
if (architecture === 'x64') return 'linux/amd64';
|
|
if (architecture === 'arm64') return 'linux/arm64';
|
|
return null;
|
|
}
|
|
|
|
export function architectureWarning(recipe, architecture = process.arch) {
|
|
const platform = platformForArchitecture(architecture);
|
|
if (!platform || recipe.platforms?.includes(platform)) return null;
|
|
return `${recipe.image} does not support ${platform}. Docker must use ${recipe.platforms.join(', ')} emulation and startup may be slower.`;
|
|
}
|
|
|
|
function printQuickStart(recipes) {
|
|
console.log('Database test environments');
|
|
console.log('Copy one command to start an environment:');
|
|
console.log('');
|
|
for (const recipe of recipes) console.log(` make db DB=${recipeSelector(recipe)}`);
|
|
console.log('');
|
|
console.log('Optional parameters:');
|
|
console.log(' DB_BIND_ADDRESS=<ip> Override the default bind address (127.0.0.1).');
|
|
console.log(' DB_PORT=<port> Override the default host port.');
|
|
console.log(' DB_PASSWORD=<password> Override the default password (123456).');
|
|
console.log('');
|
|
console.log('Next steps: make db-verify DB=<product>@<version>, make db-down DB=<product>@<version>');
|
|
console.log('Tab completion: make db-completion');
|
|
}
|
|
|
|
function printCompletionSetup() {
|
|
console.log('Bash: source deploy/database/completion/dbx-make.bash');
|
|
console.log('Zsh: autoload -Uz compinit && compinit && source deploy/database/completion/_dbx-make.zsh');
|
|
console.log('PowerShell: . .\\deploy\\database\\completion\\Dbx.Make.ps1');
|
|
}
|
|
|
|
export function resolveRecipe(recipes, database, version) {
|
|
if (!database) throw new Error('DB is required. Run `make db-list` to see available environments.');
|
|
const matches = recipes.filter((item) => item.database === database && (!version || item.displayVersion === version || item.version === version));
|
|
if (matches.length === 0) throw new Error(`No database environment found for ${database}${version ? ` ${version}` : ''}.`);
|
|
if (matches.length > 1) throw new Error(`${database} has multiple versions (${matches.map((item) => item.displayVersion).join(', ')}). Use DB=${database}@<version> (or DB_VERSION).`);
|
|
return matches[0];
|
|
}
|
|
|
|
export function parseDatabaseSelection(selection, explicitVersion) {
|
|
if (!selection) return { database: selection, version: explicitVersion };
|
|
const selectorIndex = selection.lastIndexOf('@');
|
|
if (selectorIndex === -1) return { database: selection, version: explicitVersion };
|
|
|
|
const database = selection.slice(0, selectorIndex);
|
|
const selectorVersion = selection.slice(selectorIndex + 1);
|
|
if (!database || !selectorVersion) throw new Error('DB must use the format <product>@<version>, for example DB=mysql@8.4.');
|
|
if (explicitVersion && explicitVersion !== selectorVersion) {
|
|
throw new Error(`DB version ${selectorVersion} conflicts with DB_VERSION=${explicitVersion}.`);
|
|
}
|
|
return { database, version: explicitVersion || selectorVersion };
|
|
}
|
|
|
|
export function assertResetConfirmed(value) {
|
|
if (value !== '1') throw new Error('Reset deletes the environment volume. Re-run with CONFIRM=1.');
|
|
}
|
|
|
|
export function expectedContainerName(recipe) {
|
|
return `dbx-${recipe.database}-${recipe.displayVersion}`.toLowerCase().replace(/[^a-z0-9_.-]+/g, '-');
|
|
}
|
|
|
|
export function defaultHostPortMappings(compose) {
|
|
const mappings = [];
|
|
const pattern = /^\s*-\s*["']?\$\{DB_BIND_ADDRESS:-127\.0\.0\.1\}:\$\{([A-Z][A-Z0-9_]*):-([0-9]+)\}:([0-9]+)(?:\/(tcp|udp))?["']?\s*$/;
|
|
for (const line of compose.split(/\r?\n/)) {
|
|
const match = line.match(pattern);
|
|
if (!match) continue;
|
|
mappings.push({ environment: match[1], hostPort: Number(match[2]), containerPort: Number(match[3]), protocol: match[4] || 'tcp' });
|
|
}
|
|
return mappings;
|
|
}
|
|
|
|
export function recipePortMappings(recipe) {
|
|
const composePath = join(recipe.directory, 'compose.yaml');
|
|
if (!existsSync(composePath)) return '';
|
|
const mappings = defaultHostPortMappings(readFileSync(composePath, 'utf8')).sort((left, right) => (
|
|
left.hostPort - right.hostPort
|
|
|| left.containerPort - right.containerPort
|
|
|| left.protocol.localeCompare(right.protocol)
|
|
));
|
|
return mappings.map((mapping) => {
|
|
const sharesPortWithAnotherProtocol = mappings.some((candidate) => (
|
|
candidate !== mapping
|
|
&& candidate.containerPort === mapping.containerPort
|
|
&& candidate.hostPort === mapping.hostPort
|
|
&& candidate.protocol !== mapping.protocol
|
|
));
|
|
const sourcePort = sharesPortWithAnotherProtocol
|
|
? `${mapping.containerPort}/${mapping.protocol}`
|
|
: mapping.containerPort;
|
|
return `${sourcePort} -> ${mapping.hostPort}`;
|
|
}).join(', ');
|
|
}
|
|
|
|
export function databaseListRows(recipes) {
|
|
let previousDatabase = null;
|
|
return recipes.map((recipe) => {
|
|
const database = recipe.database === previousDatabase ? '' : recipe.name;
|
|
previousDatabase = recipe.database;
|
|
return [database, recipe.displayVersion, recipePortMappings(recipe), recipe.image, recipe.platforms.join(',')];
|
|
});
|
|
}
|
|
|
|
function validateHostPorts(recipe) {
|
|
const errors = [];
|
|
const hostPorts = recipe.hostPorts;
|
|
if (!hostPorts || typeof hostPorts !== 'object' || Array.isArray(hostPorts)) return ['hostPorts must be a non-empty object'];
|
|
const entries = Object.entries(hostPorts);
|
|
if (entries.length === 0) return ['hostPorts must be a non-empty object'];
|
|
const [start, end] = DEFAULT_HOST_PORT_RANGES[recipe.database] ?? [];
|
|
if (!start) {
|
|
errors.push(`database ${recipe.database} has no allocated default host port range`);
|
|
}
|
|
for (const [environment, port] of entries) {
|
|
if (!/^[A-Z][A-Z0-9_]*$/.test(environment)) errors.push(`hostPorts key ${environment} must be an environment variable name`);
|
|
if (!Number.isInteger(port) || port < start || port > end) {
|
|
errors.push(`hostPorts.${environment} must be an integer in the ${start}-${end} range`);
|
|
}
|
|
}
|
|
if (new Set(entries.map(([, port]) => port)).size !== entries.length) errors.push('hostPorts must not reuse a port within a recipe');
|
|
if (recipe.connection?.port !== hostPorts.DB_PORT) errors.push('connection.port must match hostPorts.DB_PORT');
|
|
return errors;
|
|
}
|
|
|
|
export function validateHostPortAllocation(recipes) {
|
|
const errors = [];
|
|
const owners = new Map();
|
|
for (const recipe of recipes) {
|
|
for (const [environment, port] of Object.entries(recipe.hostPorts ?? {})) {
|
|
if (!Number.isInteger(port)) continue;
|
|
const owner = `${recipeSelector(recipe)} (${environment})`;
|
|
const existing = owners.get(port);
|
|
if (existing) errors.push(`host port ${port} is allocated to both ${existing} and ${owner}`);
|
|
else owners.set(port, owner);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export function validateRecipe(recipe) {
|
|
const errors = [];
|
|
for (const field of ['database', 'version', 'displayVersion', 'service', 'connection', 'hostPorts', 'smoke', 'shell', 'defaultPort']) {
|
|
if (recipe[field] === undefined) errors.push(`missing ${field}`);
|
|
}
|
|
if (!recipe.image || !/^[^\s]+:[^\s]+$/.test(recipe.image)) errors.push('image must be a pinned image reference');
|
|
if (recipe.connection?.host !== '127.0.0.1') errors.push('connection.host must be 127.0.0.1');
|
|
if (!Number.isInteger(recipe.defaultPort) || recipe.defaultPort < 1 || recipe.defaultPort > 65534) {
|
|
errors.push('defaultPort must be an integer between 1 and 65534');
|
|
}
|
|
errors.push(...validateHostPorts(recipe));
|
|
if (recipe.connection?.authentication === 'none') {
|
|
if (recipe.connection.password !== undefined) errors.push('unauthenticated recipes must not declare connection.password');
|
|
} else if (recipe.connection?.password !== DEFAULT_PASSWORD) errors.push(`connection.password must be ${DEFAULT_PASSWORD}`);
|
|
if (recipe.database === 'redis' ? recipe.connection?.database !== 0 : recipe.connection?.database !== DEFAULT_DATABASE) {
|
|
errors.push(`connection.database must be ${recipe.database === 'redis' ? '0 for Redis' : DEFAULT_DATABASE}`);
|
|
}
|
|
if (!Array.isArray(recipe.platforms) || recipe.platforms.length === 0) errors.push('platforms must not be empty');
|
|
else if (recipe.platforms.some((platform) => !['linux/amd64', 'linux/arm64'].includes(platform))) {
|
|
errors.push('platforms contains an unsupported platform');
|
|
}
|
|
if (basename(recipe.directory) !== recipe.displayVersion || basename(dirname(recipe.directory)) !== recipe.database) {
|
|
errors.push('recipe directory must match database/displayVersion');
|
|
}
|
|
const composePath = join(recipe.directory, 'compose.yaml');
|
|
if (!existsSync(composePath)) errors.push('missing compose.yaml');
|
|
else {
|
|
const compose = readFileSync(composePath, 'utf8');
|
|
if (!compose.includes(`container_name: ${expectedContainerName(recipe)}`)) {
|
|
errors.push(`compose.yaml container_name must be ${expectedContainerName(recipe)}`);
|
|
}
|
|
if (/image:\s*[^\n]*:latest(?:\s|$)/.test(compose)) errors.push('compose.yaml must not use latest');
|
|
if (!/healthcheck\s*:/.test(compose)) errors.push('compose.yaml must define a healthcheck');
|
|
if (!/^volumes\s*:/m.test(compose)) errors.push('compose.yaml must define a named volume');
|
|
if (!compose.includes(`image: ${recipe.image}`)) errors.push('compose.yaml image must match recipe.image');
|
|
if (recipe.platforms?.length === 1 && !compose.includes(`platform: ${recipe.platforms[0]}`)) {
|
|
errors.push(`single-platform image must set compose platform to ${recipe.platforms[0]}`);
|
|
}
|
|
if (!allPortMappingsDefaultToLoopback(compose)) {
|
|
errors.push('compose.yaml ports must default every mapping to 127.0.0.1 via DB_BIND_ADDRESS');
|
|
}
|
|
if (!serviceHasNamedVolume(compose, recipe.service)) errors.push('target service must mount a named volume');
|
|
const portMappings = defaultHostPortMappings(compose);
|
|
const mappedPorts = new Map();
|
|
for (const mapping of portMappings) {
|
|
const previousPort = mappedPorts.get(mapping.environment);
|
|
if (previousPort !== undefined && previousPort !== mapping.hostPort) {
|
|
errors.push(`compose.yaml uses conflicting defaults for ${mapping.environment}`);
|
|
}
|
|
mappedPorts.set(mapping.environment, mapping.hostPort);
|
|
}
|
|
const configuredPorts = Object.entries(recipe.hostPorts ?? {});
|
|
if (mappedPorts.size !== configuredPorts.length || configuredPorts.some(([environment, port]) => mappedPorts.get(environment) !== port)) {
|
|
errors.push('compose.yaml port defaults must match recipe hostPorts');
|
|
}
|
|
}
|
|
const initDirectory = join(recipe.directory, 'init');
|
|
if (!existsSync(initDirectory) || readdirSync(initDirectory).length === 0) errors.push('init directory must contain a file');
|
|
if (!Array.isArray(recipe.smoke?.steps) || recipe.smoke.steps.length === 0) errors.push('smoke.steps must not be empty');
|
|
for (const step of recipe.smoke?.steps ?? []) {
|
|
if (!step.name || !Array.isArray(step.command) || step.command.length === 0) errors.push('every smoke step needs a name and command');
|
|
if (typeof step.expect !== 'string' || step.expect.length === 0) errors.push('every smoke step needs an expected output');
|
|
}
|
|
if (!Array.isArray(recipe.shell) || recipe.shell.length === 0) errors.push('shell must be a non-empty command array');
|
|
if (recipe.bootstrap !== undefined) {
|
|
if (!recipe.bootstrap || typeof recipe.bootstrap !== 'object') errors.push('bootstrap must be an object');
|
|
if (!recipe.bootstrap?.check || !Array.isArray(recipe.bootstrap.check.command) || recipe.bootstrap.check.command.length === 0 || typeof recipe.bootstrap.check.expect !== 'string' || recipe.bootstrap.check.expect.length === 0) {
|
|
errors.push('bootstrap.check needs a command and expected output');
|
|
}
|
|
if (!Array.isArray(recipe.bootstrap?.steps) || recipe.bootstrap.steps.length === 0) errors.push('bootstrap.steps must not be empty');
|
|
for (const step of recipe.bootstrap?.steps ?? []) {
|
|
if (!step.name || !Array.isArray(step.command) || step.command.length === 0) errors.push('every bootstrap step needs a name and command');
|
|
if (typeof step.expect !== 'string' || step.expect.length === 0) errors.push('every bootstrap step needs an expected output');
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function indentation(line) {
|
|
return line.length - line.trimStart().length;
|
|
}
|
|
|
|
function serviceBlock(compose, service) {
|
|
const lines = compose.split(/\r?\n/);
|
|
const start = lines.findIndex((line) => new RegExp(`^\\s{2}${service.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:\\s*$`).test(line));
|
|
if (start === -1) return [];
|
|
const block = [];
|
|
for (let index = start + 1; index < lines.length; index += 1) {
|
|
const line = lines[index];
|
|
if (line.trim() && indentation(line) <= 2) break;
|
|
block.push(line);
|
|
}
|
|
return block;
|
|
}
|
|
|
|
export function allPortMappingsDefaultToLoopback(compose) {
|
|
const lines = compose.split(/\r?\n/);
|
|
let portsIndent = null;
|
|
let foundMapping = false;
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
const lineIndent = indentation(line);
|
|
if (/^ports\s*:/.test(trimmed)) {
|
|
portsIndent = lineIndent;
|
|
const inlineMappings = trimmed.match(/['\"]([^'\"]+)['\"]/g) ?? [];
|
|
if (inlineMappings.length > 0) {
|
|
foundMapping = true;
|
|
if (inlineMappings.some((mapping) => !mapping.slice(1, -1).startsWith('${DB_BIND_ADDRESS:-127.0.0.1}:'))) return false;
|
|
}
|
|
continue;
|
|
}
|
|
if (portsIndent === null) continue;
|
|
if (trimmed && lineIndent <= portsIndent) {
|
|
portsIndent = null;
|
|
continue;
|
|
}
|
|
if (!trimmed.startsWith('-')) continue;
|
|
foundMapping = true;
|
|
const mapping = trimmed.slice(1).trim().replace(/^['\"]|['\"]$/g, '');
|
|
if (!mapping.startsWith('${DB_BIND_ADDRESS:-127.0.0.1}:')) return false;
|
|
}
|
|
return foundMapping;
|
|
}
|
|
|
|
export function serviceHasNamedVolume(compose, service) {
|
|
const block = serviceBlock(compose, service);
|
|
let volumesIndent = null;
|
|
for (const line of block) {
|
|
const trimmed = line.trim();
|
|
const lineIndent = indentation(line);
|
|
if (/^volumes\s*:/.test(trimmed)) {
|
|
volumesIndent = lineIndent;
|
|
continue;
|
|
}
|
|
if (volumesIndent === null) continue;
|
|
if (trimmed && lineIndent <= volumesIndent) {
|
|
volumesIndent = null;
|
|
continue;
|
|
}
|
|
if (!trimmed.startsWith('-')) continue;
|
|
const mount = trimmed.slice(1).trim().replace(/^['\"]|['\"]$/g, '');
|
|
if (/^[A-Za-z0-9][A-Za-z0-9_.-]*:/.test(mount) && !mount.startsWith('./') && !mount.startsWith('../')) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function composeArgs(recipe, ...args) {
|
|
const project = (process.env.DB_PROJECT || `dbx-${basename(REPO_ROOT)}-${recipe.database}-${recipe.displayVersion}`).toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
return ['compose', '--project-name', project, '--file', join(recipe.directory, 'compose.yaml'), ...args];
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const result = spawnSync(command, args, { cwd: options.cwd ?? REPO_ROOT, env: process.env, encoding: 'utf8', stdio: options.capture ? 'pipe' : 'inherit' });
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) {
|
|
const detail = options.capture ? `\n${result.stderr || result.stdout}` : '';
|
|
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}.${detail}`);
|
|
}
|
|
return options.capture ? `${result.stdout ?? ''}${result.stderr ?? ''}` : '';
|
|
}
|
|
|
|
function runCompose(recipe, args, options) {
|
|
return run('docker', composeArgs(recipe, ...args), options);
|
|
}
|
|
|
|
function tryRunCompose(recipe, args) {
|
|
const result = spawnSync('docker', composeArgs(recipe, ...args), { cwd: REPO_ROOT, env: process.env, encoding: 'utf8', stdio: 'pipe' });
|
|
if (result.error) throw result.error;
|
|
return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}` };
|
|
}
|
|
|
|
export function expandSmokeCommand(command, recipe, environment = process.env) {
|
|
const values = {
|
|
DB_PASSWORD: environment.DB_PASSWORD || recipe.connection.password,
|
|
DB_PORT: environment.DB_PORT || String(recipe.connection.port),
|
|
};
|
|
return command.map((value) => value.replace(/\$\{(DB_PASSWORD|DB_PORT)\}/g, (_, name) => values[name]));
|
|
}
|
|
|
|
export function dbxConnectionDeepLink(recipe, environment = process.env) {
|
|
const type = recipe.deepLinkType || DBX_DEEP_LINK_TYPES[recipe.database];
|
|
if (!type) return null;
|
|
|
|
const { connection } = recipe;
|
|
const params = new URLSearchParams({
|
|
type,
|
|
name: `${recipe.name} ${recipe.version} (local)`,
|
|
host: connection.host,
|
|
port: environment.DB_PORT || String(connection.port),
|
|
database: String(connection.database),
|
|
});
|
|
if (connection.username) params.set('user', connection.username);
|
|
if (connection.password) params.set('password', environment.DB_PASSWORD || connection.password);
|
|
if (connection.urlParams) params.set('url_params', connection.urlParams);
|
|
return `dbx://connection/new?${params}`;
|
|
}
|
|
|
|
function ensureBootstrap(recipe) {
|
|
if (!recipe.bootstrap) return;
|
|
// Keep one-shot setup synchronous after Compose health checks so --wait cannot return before credentials exist.
|
|
const checkCommand = expandSmokeCommand(recipe.bootstrap.check.command, recipe);
|
|
const check = tryRunCompose(recipe, ['exec', '-T', recipe.service, ...checkCommand]);
|
|
if (check.ok && check.output.includes(recipe.bootstrap.check.expect)) return;
|
|
|
|
for (const step of recipe.bootstrap.steps) {
|
|
const command = expandSmokeCommand(step.command, recipe);
|
|
const output = runCompose(recipe, ['exec', '-T', recipe.service, ...command], { capture: true });
|
|
if (!output.includes(step.expect)) throw new Error(`Bootstrap check did not contain expected text: ${step.expect}\n${output}`);
|
|
console.log(`OK ${step.name}`);
|
|
}
|
|
}
|
|
|
|
function printConnection(recipe) {
|
|
const connection = { ...recipe.connection };
|
|
if (process.env.DB_PORT) connection.port = Number(process.env.DB_PORT);
|
|
if (process.env.DB_PASSWORD) connection.password = process.env.DB_PASSWORD;
|
|
console.log(`${recipe.name} (${recipe.version})`);
|
|
for (const [key, value] of Object.entries(connection)) console.log(`${key}: ${value}`);
|
|
const deepLink = dbxConnectionDeepLink(recipe);
|
|
if (deepLink) console.log(`DBX connection link: ${deepLink}`);
|
|
else console.log(`DBX connection link: unavailable (DBX has no compatible ${recipe.name} connection type)`);
|
|
if (recipe.notes) console.log(`notes: ${recipe.notes}`);
|
|
const warning = architectureWarning(recipe);
|
|
if (warning) console.warn(`warning: ${warning}`);
|
|
}
|
|
|
|
function checkRecipes(recipes) {
|
|
let failed = false;
|
|
const allocationErrors = validateHostPortAllocation(recipes);
|
|
if (allocationErrors.length > 0) {
|
|
failed = true;
|
|
for (const error of allocationErrors) console.error(`FAIL host port allocation: ${error}`);
|
|
}
|
|
for (const recipe of recipes) {
|
|
const errors = validateRecipe(recipe);
|
|
if (errors.length) {
|
|
failed = true;
|
|
console.error(`FAIL ${recipe.database}/${recipe.displayVersion}: ${errors.join('; ')}`);
|
|
continue;
|
|
}
|
|
const result = spawnSync('docker', composeArgs(recipe, 'config', '--format', 'json'), {
|
|
encoding: 'utf8',
|
|
env: { ...process.env, DB_BIND_ADDRESS: '' },
|
|
});
|
|
if (result.error?.code === 'ENOENT') {
|
|
console.log(`OK ${recipe.database}/${recipe.displayVersion} (static checks; Docker unavailable)`);
|
|
} else if (result.status !== 0) {
|
|
failed = true;
|
|
console.error(`FAIL ${recipe.database}/${recipe.displayVersion}: docker compose config failed\n${result.stderr}`);
|
|
} else {
|
|
try {
|
|
validateRenderedCompose(recipe, JSON.parse(result.stdout));
|
|
console.log(`OK ${recipe.database}/${recipe.displayVersion}`);
|
|
} catch (error) {
|
|
failed = true;
|
|
console.error(`FAIL ${recipe.database}/${recipe.displayVersion}: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
if (failed) throw new Error('Database environment checks failed.');
|
|
}
|
|
|
|
export function validateRenderedCompose(recipe, rendered) {
|
|
const service = rendered.services?.[recipe.service];
|
|
if (!service) throw new Error('rendered Compose config is missing the target service');
|
|
if (service.container_name !== expectedContainerName(recipe)) {
|
|
throw new Error(`rendered target service container_name must be ${expectedContainerName(recipe)}`);
|
|
}
|
|
if (!service.healthcheck?.test) throw new Error('rendered target service is missing a healthcheck');
|
|
const ports = service.ports ?? [];
|
|
if (ports.length === 0 || ports.some((port) => typeof port !== 'object' || port.host_ip !== '127.0.0.1')) {
|
|
throw new Error('rendered target service has a port mapping that does not default to 127.0.0.1');
|
|
}
|
|
const volumes = service.volumes ?? [];
|
|
if (!volumes.some((volume) => typeof volume === 'object' && volume.type === 'volume')) {
|
|
throw new Error('rendered target service does not mount a named volume');
|
|
}
|
|
}
|
|
|
|
export function main(argv = process.argv.slice(2)) {
|
|
const [command = 'list', positionalDatabase, positionalVersion] = argv.filter((argument) => argument && argument !== '--');
|
|
const recipes = discoverRecipes();
|
|
|
|
if (command === 'list') {
|
|
console.log(formatBorderedTable(
|
|
['DATABASE', 'VERSION', 'PORTS (CONTAINER -> HOST)', 'IMAGE', 'PLATFORMS'],
|
|
databaseListRows(recipes),
|
|
true,
|
|
));
|
|
return;
|
|
}
|
|
if (command === 'quick-start') return printQuickStart(recipes);
|
|
if (command === 'completion') return printCompletionSetup();
|
|
if (command === 'selectors') {
|
|
for (const recipe of recipes) console.log(recipeSelector(recipe));
|
|
return;
|
|
}
|
|
if (command === 'make-targets') {
|
|
for (const target of discoverMakeTargets()) console.log(target);
|
|
return;
|
|
}
|
|
if (command === 'check') return checkRecipes(recipes);
|
|
|
|
const { database, version } = parseDatabaseSelection(process.env.DB || positionalDatabase, process.env.DB_VERSION || positionalVersion);
|
|
|
|
if (command === 'start' && !database) return printQuickStart(recipes);
|
|
|
|
const recipe = resolveRecipe(recipes, database, version);
|
|
switch (command) {
|
|
case 'info':
|
|
printConnection(recipe);
|
|
break;
|
|
case 'start':
|
|
case 'up':
|
|
runCompose(recipe, ['up', '-d', '--wait']);
|
|
ensureBootstrap(recipe);
|
|
printConnection(recipe);
|
|
break;
|
|
case 'status':
|
|
runCompose(recipe, ['ps']);
|
|
break;
|
|
case 'logs':
|
|
runCompose(recipe, process.env.FOLLOW === '1' ? ['logs', '--follow'] : ['logs']);
|
|
break;
|
|
case 'shell':
|
|
runCompose(recipe, ['exec', recipe.service, ...expandSmokeCommand(recipe.shell, recipe)]);
|
|
break;
|
|
case 'verify':
|
|
runCompose(recipe, ['up', '-d', '--wait']);
|
|
ensureBootstrap(recipe);
|
|
for (const step of recipe.smoke.steps) {
|
|
const command = expandSmokeCommand(step.command, recipe);
|
|
const output = runCompose(recipe, ['exec', '-T', recipe.service, ...command], { capture: true });
|
|
if (step.expect && !output.includes(step.expect)) throw new Error(`Smoke check did not contain expected text: ${step.expect}\n${output}`);
|
|
console.log(`OK ${step.name}`);
|
|
}
|
|
break;
|
|
case 'down':
|
|
runCompose(recipe, ['down', '--remove-orphans']);
|
|
break;
|
|
case 'reset':
|
|
assertResetConfirmed(process.env.CONFIRM);
|
|
runCompose(recipe, ['down', '--volumes', '--remove-orphans']);
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown command: ${command}`);
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(`Error: ${error.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|