mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:25:40 +00:00
feat(database): add Nacos Pulsar Qdrant and ZooKeeper recipes
This commit is contained in:
@@ -32,6 +32,19 @@ 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 discoverMakeTargets(makefilePath = DEFAULT_MAKEFILE_PATH) {
|
||||
if (!existsSync(makefilePath)) return [];
|
||||
const source = readFileSync(makefilePath, 'utf8').replace(/\\\r?\n/g, ' ');
|
||||
@@ -118,7 +131,9 @@ export function validateRecipe(recipe) {
|
||||
} else if (recipe.connection?.port !== recipe.defaultPort + 1) {
|
||||
errors.push('connection.port must be defaultPort + 1');
|
||||
}
|
||||
if (recipe.connection?.password !== DEFAULT_PASSWORD) errors.push(`connection.password must be ${DEFAULT_PASSWORD}`);
|
||||
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}`);
|
||||
}
|
||||
@@ -159,6 +174,17 @@ export function validateRecipe(recipe) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -249,6 +275,12 @@ 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,
|
||||
@@ -257,6 +289,21 @@ export function expandSmokeCommand(command, recipe, environment = process.env) {
|
||||
return command.map((value) => value.replace(/\$\{(DB_PASSWORD|DB_PORT)\}/g, (_, name) => values[name]));
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -321,8 +368,10 @@ export function main(argv = process.argv.slice(2)) {
|
||||
const recipes = discoverRecipes();
|
||||
|
||||
if (command === 'list') {
|
||||
console.log('DATABASE\tVERSION\tIMAGE\tPLATFORMS');
|
||||
for (const recipe of recipes) console.log(`${recipe.database}\t${recipe.displayVersion}\t${recipe.image}\t${recipe.platforms.join(',')}`);
|
||||
console.log(formatTable(
|
||||
['DATABASE', 'VERSION', 'IMAGE', 'PLATFORMS'],
|
||||
recipes.map((recipe) => [recipe.database, recipe.displayVersion, recipe.image, recipe.platforms.join(',')]),
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (command === 'quick-start') return printQuickStart(recipes);
|
||||
@@ -349,6 +398,7 @@ export function main(argv = process.argv.slice(2)) {
|
||||
case 'start':
|
||||
case 'up':
|
||||
runCompose(recipe, ['up', '-d', '--wait']);
|
||||
ensureBootstrap(recipe);
|
||||
printConnection(recipe);
|
||||
break;
|
||||
case 'status':
|
||||
@@ -362,6 +412,7 @@ export function main(argv = process.argv.slice(2)) {
|
||||
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 });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { DEFAULT_RECIPES_ROOT, allPortMappingsDefaultToLoopback, architectureWarning, assertResetConfirmed, discoverMakeTargets, discoverRecipes, expandSmokeCommand, expectedContainerName, parseDatabaseSelection, platformForArchitecture, recipeSelector, resolveRecipe, serviceHasNamedVolume, validateRecipe, validateRenderedCompose } from './database-env.mjs';
|
||||
import { DEFAULT_RECIPES_ROOT, allPortMappingsDefaultToLoopback, architectureWarning, assertResetConfirmed, discoverMakeTargets, discoverRecipes, expandSmokeCommand, expectedContainerName, formatTable, parseDatabaseSelection, platformForArchitecture, recipeSelector, resolveRecipe, serviceHasNamedVolume, validateRecipe, validateRenderedCompose } from './database-env.mjs';
|
||||
|
||||
const bashAvailable = spawnSync('bash', ['--version'], { stdio: 'ignore' }).status === 0;
|
||||
const zshAvailable = spawnSync('zsh', ['--version'], { stdio: 'ignore' }).status === 0;
|
||||
@@ -44,6 +44,21 @@ test('formats a recipe as a copyable compact selector', () => {
|
||||
assert.equal(recipeSelector({ database: 'mysql', displayVersion: '8.4' }), 'mysql@8.4');
|
||||
});
|
||||
|
||||
test('formats lists as left-aligned tables with dynamic column widths', () => {
|
||||
assert.equal(
|
||||
formatTable(
|
||||
['DATABASE', 'VERSION', 'IMAGE'],
|
||||
[
|
||||
['etcd', '3.7', 'etcd:v3.7.0'],
|
||||
['clickhouse', '24.8', 'clickhouse-server:24.8.14.39'],
|
||||
],
|
||||
),
|
||||
'DATABASE VERSION IMAGE\n'
|
||||
+ 'etcd 3.7 etcd:v3.7.0\n'
|
||||
+ 'clickhouse 24.8 clickhouse-server:24.8.14.39',
|
||||
);
|
||||
});
|
||||
|
||||
test('discovers all public Make targets for shell completion', () => {
|
||||
const targets = discoverMakeTargets();
|
||||
for (const target of ['help', 'build', 'test', 'docs', 'db', 'db-verify', 'db-completion']) {
|
||||
@@ -125,19 +140,78 @@ test('uses the approved CNB mirror images and matching recipe versions', () => {
|
||||
);
|
||||
assert.deepEqual(recipes, {
|
||||
'clickhouse@24.8': { version: '24.8.14.39', image: 'docker.cnb.cool/znb/images/clickhouse-server:24.8.14.39', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'etcd@3.7': { version: '3.7.0', image: 'docker.cnb.cool/znb/images/etcd:v3.7.0', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'kafka@4.3': { version: '4.3.1', image: 'docker.cnb.cool/znb/images/kafka:4.3.1', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'mariadb@10.11': { version: '10.11.11', image: 'docker.cnb.cool/znb/images/mariadb:10.11.11', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'mongodb@5.0': { version: '5.0.5', image: 'docker.cnb.cool/znb/images/mongo:5.0.5', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'mongodb@8.2': { version: '8.2.3', image: 'docker.cnb.cool/znb/images/mongo:8.2.3-noble', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'mysql@5.7': { version: '5.7.44', image: 'docker.cnb.cool/znb/images/mysql:5.7.44', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'mysql@8.4': { version: '8.4.6', image: 'docker.cnb.cool/znb/images/mysql:8.4.6', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'nacos@2.5': { version: '2.5.2', image: 'docker.cnb.cool/znb/images/nacos-server:v2.5.2', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'nacos@3.2': { version: '3.2.2', image: 'docker.cnb.cool/znb/images/nacos-server:v3.2.2', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'postgresql@14.23': { version: '14.23', image: 'docker.cnb.cool/znb/images/postgres:14.23', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'postgresql@17.4': { version: '17.4', image: 'docker.cnb.cool/znb/images/postgres:17.4', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'pulsar@4.2': { version: '4.2.3', image: 'docker.cnb.cool/znb/images/pulsar:4.2.3', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'qdrant@1.8': { version: '1.8.3', image: 'docker.cnb.cool/znb/images/qdrant:v1.8.3', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'redis@3.0.7': { version: '3.0.7', image: 'docker.cnb.cool/znb/images/redis:3.0.7-alpine', platforms: ['linux/amd64'] },
|
||||
'redis@7.4': { version: '7.4.9', image: 'docker.cnb.cool/znb/images/redis:7.4.9-alpine', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
'zookeeper@3.9': { version: '3.9.5', image: 'docker.cnb.cool/znb/images/zookeeper:3.9.5', platforms: ['linux/amd64', 'linux/arm64'] },
|
||||
});
|
||||
assert.equal(discoverRecipes().find((recipe) => recipeSelector(recipe) === 'redis@3.0.7').connection.username, undefined);
|
||||
});
|
||||
|
||||
test('initializes both Nacos versions with the shared administrator credentials', () => {
|
||||
for (const recipe of discoverRecipes().filter((item) => item.database === 'nacos')) {
|
||||
assert.equal(recipe.connection.username, 'nacos');
|
||||
assert.equal(recipe.connection.password, '123456');
|
||||
assert.equal(recipe.connection.namespace, 'public');
|
||||
assert.equal(recipe.bootstrap.check.expect, 'accessToken');
|
||||
assert.equal(recipe.bootstrap.steps.length, 1);
|
||||
assert.match(recipe.bootstrap.steps[0].command.join(' '), /administrator initialized/);
|
||||
|
||||
const compose = readFileSync(join(recipe.directory, 'compose.yaml'), 'utf8');
|
||||
assert.doesNotMatch(compose, /^ initialize:/m);
|
||||
assert.doesNotMatch(compose, /tail -f \/dev\/null/);
|
||||
assert.deepEqual(validateRecipe(recipe), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('configures Qdrant with the shared API key', () => {
|
||||
const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'qdrant@1.8');
|
||||
assert.equal(recipe.connection.username, '');
|
||||
assert.equal(recipe.connection.password, '123456');
|
||||
assert.equal(recipe.connection.apiKey, undefined);
|
||||
});
|
||||
|
||||
test('bootstraps etcd with the shared root credentials', () => {
|
||||
const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'etcd@3.7');
|
||||
assert.equal(recipe.connection.username, 'root');
|
||||
assert.equal(recipe.connection.password, '123456');
|
||||
assert.ok(recipe.bootstrap);
|
||||
});
|
||||
|
||||
test('bootstraps ZooKeeper with the shared Digest credentials', () => {
|
||||
const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'zookeeper@3.9');
|
||||
assert.equal(recipe.connection.username, 'root');
|
||||
assert.equal(recipe.connection.password, '123456');
|
||||
assert.equal(recipe.connection.authScheme, 'digest');
|
||||
});
|
||||
|
||||
test('keeps existing authenticated bootstrap recipes valid', () => {
|
||||
for (const selector of ['etcd@3.7', 'zookeeper@3.9']) {
|
||||
const recipe = discoverRecipes().find((item) => recipeSelector(item) === selector);
|
||||
assert.deepEqual(validateRecipe(recipe), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('marks the unauthenticated Kafka and Pulsar development recipes explicitly', () => {
|
||||
for (const selector of ['kafka@4.3', 'pulsar@4.2']) {
|
||||
const recipe = discoverRecipes().find((item) => recipeSelector(item) === selector);
|
||||
assert.equal(recipe.connection.authentication, 'none');
|
||||
assert.equal(recipe.connection.password, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('start without DB prints every copyable database command', () => {
|
||||
const repoRoot = join(DEFAULT_RECIPES_ROOT, '..', '..');
|
||||
const result = spawnSync(process.execPath, ['scripts/database-env.mjs', 'start'], {
|
||||
@@ -260,6 +334,13 @@ test('requires the shared default password and database name', () => {
|
||||
assert.match(validateRecipe(recipe).join('; '), /connection.database must be dbx/);
|
||||
});
|
||||
|
||||
test('allows recipes that explicitly declare no authentication', () => {
|
||||
const [recipe] = discoverRecipes(fixture('postgresql', '17.4'));
|
||||
recipe.connection.authentication = 'none';
|
||||
delete recipe.connection.password;
|
||||
assert.deepEqual(validateRecipe(recipe), []);
|
||||
});
|
||||
|
||||
test('requires every recipe port to be one greater than its database default port', () => {
|
||||
const [recipe] = discoverRecipes(fixture('postgresql', '17.4'));
|
||||
recipe.connection.port = 5432;
|
||||
|
||||
Reference in New Issue
Block a user