feat(database): add Nacos Pulsar Qdrant and ZooKeeper recipes

This commit is contained in:
二丫讲梵
2026-07-20 01:51:45 +08:00
committed by GitHub
parent 48dc2dee88
commit 21652bc797
25 changed files with 506 additions and 4 deletions
+54 -3
View File
@@ -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 });
+82 -1
View File
@@ -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;