From bc7e288e88b490a4c04f6508ef911bdb78a8ae22 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 24 Jul 2026 13:55:45 -0500 Subject: [PATCH] test: clean up throwaway Postgres resources Isolate temporary Postgres databases and Docker smoke resources across concurrent test runs. Preserve operation and cleanup failures together, and close shared pools before forced database removal. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../scripts/run-tests-failure.js | 15 +++ .../scripts/run-tests-failure.test.mjs | 27 ++++ reference-implementation/scripts/run-tests.js | 94 ++++++++++---- ...regation-rows-conformance-postgres.test.js | 2 +- ...ry-evidence-throughput-integration.test.js | 37 ++---- ...batch-summary-evidence-convergence.test.js | 33 +---- .../device-exporter-postgres-proof.test.js | 34 +---- .../test/device-ingest-conformance.test.js | 36 +----- .../test/helpers/postgres-temp-database.js | 84 ++++++++++++ ...st-reconcile-bounded-work-postgres.test.js | 2 +- .../postgres-record-index-bootstrap.test.js | 49 +------ ...es-record-index-idempotency-oracle.test.js | 49 +------ ...ostgres-record-index-repair-oracle.test.js | 49 +------ .../postgres-temp-database-helper.test.js | 121 ++++++++++++++++++ .../test/spine-source-boot-backfill.test.js | 48 +------ .../storage-mode-startup-boundary.test.js | 43 +------ ...er-neko-dynamic-allocator-smoke-config.mjs | 44 +++++++ .../docker-neko-dynamic-allocator-smoke.sh | 71 +++++++--- ...cker-neko-dynamic-allocator-smoke.test.mjs | 38 ++++++ scripts/docker-smoke.sh | 4 +- scripts/railway-sqlite-restart-smoke.sh | 4 +- 21 files changed, 507 insertions(+), 377 deletions(-) create mode 100644 reference-implementation/scripts/run-tests-failure.js create mode 100644 reference-implementation/scripts/run-tests-failure.test.mjs create mode 100644 reference-implementation/test/helpers/postgres-temp-database.js create mode 100644 reference-implementation/test/postgres-temp-database-helper.test.js create mode 100644 scripts/docker-neko-dynamic-allocator-smoke-config.mjs create mode 100644 scripts/docker-neko-dynamic-allocator-smoke.test.mjs diff --git a/reference-implementation/scripts/run-tests-failure.js b/reference-implementation/scripts/run-tests-failure.js new file mode 100644 index 000000000..ba88933d0 --- /dev/null +++ b/reference-implementation/scripts/run-tests-failure.js @@ -0,0 +1,15 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +export function combineTestAndCleanupFailure(testError, cleanupError, context) { + if (!testError) return cleanupError; + if (!cleanupError) return testError; + return new AggregateError([testError, cleanupError], `${context} and database cleanup both failed`); +} + +export function testProcessExitFailure(filePath, code, signal, output) { + const outcome = signal ? `exited via signal ${signal}` : `exited with code ${code}`; + const error = new Error(`Test process for ${filePath} ${outcome}\n${output}`); + error.output = output; + return error; +} diff --git a/reference-implementation/scripts/run-tests-failure.test.mjs b/reference-implementation/scripts/run-tests-failure.test.mjs new file mode 100644 index 000000000..9c7f0e239 --- /dev/null +++ b/reference-implementation/scripts/run-tests-failure.test.mjs @@ -0,0 +1,27 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { combineTestAndCleanupFailure, testProcessExitFailure } from './run-tests-failure.js'; + +test('runner preserves spawn and cleanup failures together', () => { + const spawnError = new Error('spawn failed'); + const cleanupError = new Error('drop failed'); + const failure = combineTestAndCleanupFailure(spawnError, cleanupError, 'could not start test process'); + + assert.ok(failure instanceof AggregateError); + assert.deepEqual(failure.errors, [spawnError, cleanupError]); +}); + +test('runner preserves failed child output and cleanup failure together', () => { + const output = '\n==> test/example.test.js\nnot ok 1 - expected pass\n'; + const childError = testProcessExitFailure('test/example.test.js', 1, null, output); + const cleanupError = new Error('drop failed'); + const failure = combineTestAndCleanupFailure(childError, cleanupError, 'test process failed'); + + assert.ok(failure instanceof AggregateError); + assert.deepEqual(failure.errors, [childError, cleanupError]); + assert.equal(failure.errors[0].output, output); +}); diff --git a/reference-implementation/scripts/run-tests.js b/reference-implementation/scripts/run-tests.js index 50bb900c5..cd780de60 100644 --- a/reference-implementation/scripts/run-tests.js +++ b/reference-implementation/scripts/run-tests.js @@ -6,8 +6,10 @@ import { availableParallelism } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import pg from 'pg'; import { buildScrubbedTestEnv } from './test-env.js'; +import { combineTestAndCleanupFailure, testProcessExitFailure } from './run-tests-failure.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = dirname(__dirname); @@ -26,6 +28,7 @@ const requestedConcurrency = Number.parseInt(process.env.PDPP_TEST_CONCURRENCY | // requiring any changes to individual test files. let fileCounter = 0; +const runnerId = randomBytes(4).toString('hex'); /** * Derive the admin connection URL from a per-test URL by replacing the @@ -40,8 +43,8 @@ function adminUrlFromBase(baseUrl) { } /** - * Derive a short, safe DB name from the test file path and a monotonic - * counter so concurrent workers never collide. + * Derive a short, safe DB name from the test file path, a per-run random ID, + * and a monotonic counter so concurrent runners and workers never collide. */ function deriveDbName(filePath) { // Strip directory and extension; keep only alphanumeric/underscore chars. @@ -52,9 +55,11 @@ function deriveDbName(filePath) { .replace(/\.[^.]+$/, '') .replace(/[^a-z0-9_]/gi, '_') .toLowerCase() - .slice(0, 40); + // PostgreSQL identifiers are limited to 63 bytes. The fixed prefix, + // random ID, and a base-36 counter leave 38 bytes for the file stem. + .slice(0, 38); fileCounter += 1; - return `pdpp_test_${base}_${fileCounter}`; + return `pdpp_test_${base}_${runnerId}_${fileCounter.toString(36)}`; } // Per-file databases currently allocated. Tracked so that if the runner @@ -93,6 +98,10 @@ async function allocateTestDb(filePath, baseUrl) { try { await client.connect(); // Identifier is safe: deriveDbName produces only [a-z0-9_] chars. + // A prior hard-killed runner can leave an allocated name behind. Remove + // it before allocating a fresh DB; the per-run random ID avoids collisions + // with any concurrently running test runner. + await client.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`); await client.query(`CREATE DATABASE "${dbName}"`); await client.end(); } catch (err) { @@ -106,18 +115,31 @@ async function allocateTestDb(filePath, baseUrl) { testUrl.pathname = `/${dbName}`; const allocation = { url: testUrl.toString() }; + let releasePromise; - allocation.release = async function release() { - activeAllocations.delete(allocation); - const drop = new pg.Client({ connectionString: adminUrl }); - try { - await drop.connect(); - await drop.query(`DROP DATABASE IF EXISTS "${dbName}"`); - await drop.end(); - } catch (err) { - try { await drop.end(); } catch (_) {} - process.stderr.write(`[run-tests] WARN: could not drop test DB ${dbName}: ${err.message}\n`); - } + allocation.release = function release() { + if (releasePromise) return releasePromise; + + releasePromise = (async () => { + const drop = new pg.Client({ connectionString: adminUrl }); + try { + await drop.connect(); + // FORCE also closes a leaked test pool, so cleanup cannot silently leave + // a per-file database behind after a failed test. + await drop.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`); + await drop.end(); + activeAllocations.delete(allocation); + } catch (err) { + try { await drop.end(); } catch (_) {} + throw new Error(`[run-tests] could not drop test DB ${dbName}: ${err.message}`, { cause: err }); + } + })().catch((error) => { + // Keep failed allocations eligible for signal cleanup to retry. + releasePromise = undefined; + throw error; + }); + + return releasePromise; }; // Track the live allocation and arm the runner-level signal cleanup so a @@ -149,6 +171,13 @@ async function runNodeTest(filePath, extraArgs) { env: childEnv, }); let output = ''; + let settled = false; + + const settle = (outcome, value) => { + if (settled) return; + settled = true; + outcome(value); + }; child.stdout.on('data', (chunk) => { output += chunk.toString(); @@ -157,24 +186,45 @@ async function runNodeTest(filePath, extraArgs) { output += chunk.toString(); }); - child.on('error', (err) => { - if (allocation) allocation.release().finally(() => reject(err)); - else reject(err); + child.on('error', async (err) => { + let cleanupError; + try { + if (allocation) await allocation.release(); + } catch (error) { + cleanupError = error; + } + settle(reject, combineTestAndCleanupFailure(err, cleanupError, `could not start test process for ${filePath}`)); }); - child.on('exit', (code, signal) => { + child.on('exit', async (code, signal) => { + const testError = signal || code !== 0 + ? testProcessExitFailure(filePath, code, signal, `\n==> ${filePath}\n${output}`) + : undefined; const finish = () => { if (signal) { - reject(new Error(`Test process for ${filePath} exited via signal ${signal}`)); + settle(reject, testError); return; } - resolve({ + settle(resolve, { filePath, exitCode: code ?? 1, output: `\n==> ${filePath}\n${output}`, }); }; if (allocation) { - allocation.release().finally(finish); + let cleanupError; + try { + await allocation.release(); + } catch (error) { + cleanupError = error; + } + if (cleanupError) { + settle( + reject, + combineTestAndCleanupFailure(testError, cleanupError, `test process failed for ${filePath}`), + ); + return; + } + finish(); } else { finish(); } diff --git a/reference-implementation/test/aggregation-rows-conformance-postgres.test.js b/reference-implementation/test/aggregation-rows-conformance-postgres.test.js index 132f03ea3..36b527eca 100644 --- a/reference-implementation/test/aggregation-rows-conformance-postgres.test.js +++ b/reference-implementation/test/aggregation-rows-conformance-postgres.test.js @@ -17,7 +17,7 @@ * registered so the suite remains visible in CI output without failing. * * Target for local runs: - * docker run -d --name pg-pilot -p 55463:5432 \ + * docker run --rm -d --name pg-pilot -p 55463:5432 \ * -e POSTGRES_USER=pdpp -e POSTGRES_PASSWORD=pdpp \ * -e POSTGRES_DB=pdpp_pilot \ * pgvector/pgvector:pg16 diff --git a/reference-implementation/test/connector-summary-evidence-throughput-integration.test.js b/reference-implementation/test/connector-summary-evidence-throughput-integration.test.js index 4c090b40d..e8aa25f24 100644 --- a/reference-implementation/test/connector-summary-evidence-throughput-integration.test.js +++ b/reference-implementation/test/connector-summary-evidence-throughput-integration.test.js @@ -35,6 +35,7 @@ import { reconcileConnectorSummaryEvidence } from '../server/connector-summary-e import { deleteAllRecordsForConnector, ingestRecord } from '../server/records.js'; import { closePostgresStorage, initPostgresStorage, postgresQuery } from '../server/postgres-storage.js'; import { dedicatedPostgresTestUrl } from './helpers/dedicated-postgres-test-url.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; const OWNER = 'owner_local'; const NOW = '2026-07-17T00:00:00.000Z'; @@ -99,18 +100,6 @@ async function seedInstancePostgres(connectorInstanceId, connectorId) { ); } -function databaseUrl(url, name) { - const parsed = new URL(url); - parsed.pathname = `/${name}`; - return parsed.toString(); -} - -function adminUrl(url) { - const parsed = new URL(url); - parsed.pathname = '/postgres'; - return parsed.toString(); -} - let uniquePgDb = 0; /** @@ -121,25 +110,15 @@ let uniquePgDb = 0; * loopback-only test listener, dropped again on the way out. */ async function withTemporaryPostgres(fn) { - const pg = await import('pg'); - const { Pool } = pg.default; uniquePgDb += 1; - const admin = new Pool({ connectionString: adminUrl(DEDICATED_POSTGRES_URL) }); const database = `pdpp_summary_throughput_${process.pid}_${Date.now()}_${uniquePgDb}`; - await admin.query(`DROP DATABASE IF EXISTS "${database}"`); - await admin.query(`CREATE DATABASE "${database}"`); - try { - await initPostgresStorage({ backend: 'postgres', databaseUrl: databaseUrl(DEDICATED_POSTGRES_URL, database) }); - await fn(); - } finally { - await closePostgresStorage().catch(() => undefined); - await admin.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database], - ).catch(() => undefined); - await admin.query(`DROP DATABASE IF EXISTS "${database}"`).catch(() => undefined); - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: DEDICATED_POSTGRES_URL, databaseName: database, closeConnections: closePostgresStorage }, + async (databaseUrl) => { + await initPostgresStorage({ backend: 'postgres', databaseUrl }); + await fn(); + }, + ); } function storageTargetFor(connectorId, connectorInstanceId) { diff --git a/reference-implementation/test/device-batch-summary-evidence-convergence.test.js b/reference-implementation/test/device-batch-summary-evidence-convergence.test.js index a888e244c..f125db3c3 100644 --- a/reference-implementation/test/device-batch-summary-evidence-convergence.test.js +++ b/reference-implementation/test/device-batch-summary-evidence-convergence.test.js @@ -64,6 +64,7 @@ import { import { __setPostgresRecordSortBackfillPhaseHookForTest } from '../server/postgres-records.js'; import { closePostgresStorage, postgresQuery } from '../server/postgres-storage.js'; import { dedicatedPostgresTestUrl } from './helpers/dedicated-postgres-test-url.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; const DEDICATED_POSTGRES_URL = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); const PROTOCOL_HEADERS = { 'X-PDPP-Collector-Protocol': COLLECTOR_PROTOCOL_VERSION }; @@ -189,36 +190,12 @@ function deviceIngestUrl(asUrl, device) { return `${asUrl}/_ref/device-exporters/${encodeURIComponent(device.device_id)}/ingest-batches`; } -function databaseUrl(url, name) { - const parsed = new URL(url); - parsed.pathname = `/${name}`; - return parsed.toString(); -} - -function adminUrl(url) { - const parsed = new URL(url); - parsed.pathname = '/postgres'; - return parsed.toString(); -} - async function withTemporaryPostgres(fn) { - const pg = await import('pg'); - const { Pool } = pg.default; - const admin = new Pool({ connectionString: adminUrl(DEDICATED_POSTGRES_URL) }); const database = `pdpp_devbatch_summary_${process.pid}_${Date.now()}_${unique}`; - await admin.query(`DROP DATABASE IF EXISTS "${database}"`); - await admin.query(`CREATE DATABASE "${database}"`); - try { - await fn(databaseUrl(DEDICATED_POSTGRES_URL, database)); - } finally { - await closePostgresStorage().catch(() => undefined); - await admin.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database], - ).catch(() => undefined); - await admin.query(`DROP DATABASE IF EXISTS "${database}"`).catch(() => undefined); - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: DEDICATED_POSTGRES_URL, databaseName: database, closeConnections: closePostgresStorage }, + fn, + ); } function createDriver(kind, server) { diff --git a/reference-implementation/test/device-exporter-postgres-proof.test.js b/reference-implementation/test/device-exporter-postgres-proof.test.js index 5f44dd441..7e269b1a9 100644 --- a/reference-implementation/test/device-exporter-postgres-proof.test.js +++ b/reference-implementation/test/device-exporter-postgres-proof.test.js @@ -18,6 +18,7 @@ import { closeDb, getDb, initDb } from '../server/db.js'; import { ingestRecord, setClientEventEnqueueHook } from '../server/records.js'; import { makeLocalTransformerBackend } from '../server/search-semantic.js'; import { dedicatedPostgresTestUrl } from './helpers/dedicated-postgres-test-url.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; import { bootstrapPostgresSchema, closePostgresStorage, @@ -43,37 +44,12 @@ function tempDbName() { return `pdpp_device_ingest_${process.pid}_${Date.now()}_${tempCounter}`; } -function databaseUrl(url, name) { - const parsed = new URL(url); - parsed.pathname = `/${name}`; - return parsed.toString(); -} - -function adminUrl(url) { - const parsed = new URL(url); - parsed.pathname = '/postgres'; - return parsed.toString(); -} - async function withTempPostgres(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); const name = tempDbName(); - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - const url = databaseUrl(POSTGRES_URL, name); - try { - await fn(url); - } finally { - await closePostgresStorage().catch(() => undefined); - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ).catch(() => undefined); - await admin.query(`DROP DATABASE IF EXISTS "${name}"`).catch(() => undefined); - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: name, closeConnections: closePostgresStorage }, + fn, + ); } async function closeServer(server) { diff --git a/reference-implementation/test/device-ingest-conformance.test.js b/reference-implementation/test/device-ingest-conformance.test.js index 941e13345..806ce53cf 100644 --- a/reference-implementation/test/device-ingest-conformance.test.js +++ b/reference-implementation/test/device-ingest-conformance.test.js @@ -6,8 +6,6 @@ import { createHash } from 'node:crypto'; import { setImmediate as yieldImmediate } from 'node:timers/promises'; import test from 'node:test'; -import pg from 'pg'; - import { __setRegisterConnectorPhaseHookForTest, registerConnector } from '../server/auth.js'; import { COLLECTOR_PROTOCOL_VERSION } from '../server/collector-protocol.ts'; import { @@ -33,8 +31,8 @@ import { configureSemanticBackend, semanticIndexBackfillForManifest } from '../s import { closePostgresStorage, postgresQuery } from '../server/postgres-storage.js'; import { __setPostgresRecordSortBackfillPhaseHookForTest } from '../server/postgres-records.js'; import { dedicatedPostgresTestUrl } from './helpers/dedicated-postgres-test-url.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; -const { Pool } = pg; const DEDICATED_POSTGRES_URL = dedicatedPostgresTestUrl(process.env.PDPP_TEST_POSTGRES_URL); const PROTOCOL_HEADERS = { 'X-PDPP-Collector-Protocol': COLLECTOR_PROTOCOL_VERSION }; let unique = 0; @@ -228,36 +226,12 @@ function deviceIngestUrl(asUrl, device) { return `${asUrl}/_ref/device-exporters/${encodeURIComponent(device.device_id)}/ingest-batches`; } -function databaseUrl(url, name) { - const parsed = new URL(url); - parsed.pathname = `/${name}`; - return parsed.toString(); -} - -function adminUrl(url) { - const parsed = new URL(url); - parsed.pathname = '/postgres'; - return parsed.toString(); -} - async function withTemporaryPostgres(fn) { - const admin = new Pool({ connectionString: adminUrl(DEDICATED_POSTGRES_URL) }); const database = `pdpp_ingest_oracle_${process.pid}_${Date.now()}_${unique}`; - await admin.query(`DROP DATABASE IF EXISTS "${database}"`); - await admin.query(`CREATE DATABASE "${database}"`); - try { - await fn(databaseUrl(DEDICATED_POSTGRES_URL, database)); - } finally { - await closePostgresStorage().catch(() => undefined); - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [database], - ).catch(() => undefined); - await admin.query(`DROP DATABASE IF EXISTS "${database}"`).catch(() => undefined); - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: DEDICATED_POSTGRES_URL, databaseName: database, closeConnections: closePostgresStorage }, + fn, + ); } async function withBackend(kind, fn) { diff --git a/reference-implementation/test/helpers/postgres-temp-database.js b/reference-implementation/test/helpers/postgres-temp-database.js new file mode 100644 index 000000000..24759867f --- /dev/null +++ b/reference-implementation/test/helpers/postgres-temp-database.js @@ -0,0 +1,84 @@ +import pg from 'pg'; + +const { Pool } = pg; + +function adminUrl(connectionString) { + const url = new URL(connectionString); + url.pathname = '/postgres'; + return url.toString(); +} + +function databaseUrl(connectionString, databaseName) { + const url = new URL(connectionString); + url.pathname = `/${databaseName}`; + return url.toString(); +} + +function quotedIdentifier(identifier) { + return `"${identifier.replaceAll('"', '""')}"`; +} + +/** + * Run a callback against a disposable database on a shared Postgres cluster. + * + * `DROP DATABASE ... WITH (FORCE)` is deliberate: test failures or a missed + * pool shutdown must not turn into a durable database leak on the proof + * cluster. The repository's Postgres test image is pg16, which supports it. + */ +export async function withTemporaryPostgresDatabase( + { connectionString, databaseName, closeConnections }, + callback, +) { + const admin = new Pool({ connectionString: adminUrl(connectionString) }); + const database = quotedIdentifier(databaseName); + let created = false; + let result; + let operationError; + + try { + await admin.query(`DROP DATABASE IF EXISTS ${database} WITH (FORCE)`); + await admin.query(`CREATE DATABASE ${database}`); + created = true; + result = await callback(databaseUrl(connectionString, databaseName)); + } catch (error) { + operationError = error; + } + + const cleanupErrors = []; + + if (created) { + if (closeConnections) { + try { + await closeConnections(); + } catch (error) { + cleanupErrors.push(error); + } + } + + try { + await admin.query(`DROP DATABASE IF EXISTS ${database} WITH (FORCE)`); + } catch (error) { + cleanupErrors.push(error); + } + } + + try { + await admin.end(); + } catch (error) { + cleanupErrors.push(error); + } + + if (operationError && cleanupErrors.length > 0) { + throw new AggregateError( + [operationError, ...cleanupErrors], + `temporary Postgres database operation and cleanup both failed for ${databaseName}`, + ); + } + if (operationError) throw operationError; + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, `could not clean up temporary Postgres database ${databaseName}`); + } + + return result; +} diff --git a/reference-implementation/test/polyfill-manifest-reconcile-bounded-work-postgres.test.js b/reference-implementation/test/polyfill-manifest-reconcile-bounded-work-postgres.test.js index 506ccd54f..89ac0bb58 100644 --- a/reference-implementation/test/polyfill-manifest-reconcile-bounded-work-postgres.test.js +++ b/reference-implementation/test/polyfill-manifest-reconcile-bounded-work-postgres.test.js @@ -31,7 +31,7 @@ * pagination loop runs and issues real `records` queries. * * Target for local runs: - * docker run -d --name pg-pilot -p 55463:5432 \ + * docker run --rm -d --name pg-pilot -p 55463:5432 \ * -e POSTGRES_USER=pdpp -e POSTGRES_PASSWORD=pdpp \ * -e POSTGRES_DB=pdpp_pilot \ * pgvector/pgvector:pg16 diff --git a/reference-implementation/test/postgres-record-index-bootstrap.test.js b/reference-implementation/test/postgres-record-index-bootstrap.test.js index fc6e6bd86..8150722fe 100644 --- a/reference-implementation/test/postgres-record-index-bootstrap.test.js +++ b/reference-implementation/test/postgres-record-index-bootstrap.test.js @@ -4,15 +4,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import pg from 'pg'; - import { closePostgresStorage, getPostgresPool, initPostgresStorage, } from '../server/postgres-storage.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; -const { Pool } = pg; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; let tempCounter = 0; @@ -21,48 +19,11 @@ function tempDbName() { return `pdpp_record_index_boot_${process.pid}_${tempCounter}`; } -function adminUrl(url) { - const u = new URL(url); - u.pathname = '/postgres'; - return u.toString(); -} - -function dbUrl(url, dbName) { - const u = new URL(url); - u.pathname = `/${dbName}`; - return u.toString(); -} - async function withTempDb(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); - const name = tempDbName(); - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - } catch (err) { - await admin.end(); - throw err; - } - const url = dbUrl(POSTGRES_URL, name); - try { - await fn(url); - } finally { - try { - await closePostgresStorage(); - } catch {} - try { - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ); - } catch {} - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - } catch {} - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: tempDbName(), closeConnections: closePostgresStorage }, + fn, + ); } async function readIndex(pool, indexName) { diff --git a/reference-implementation/test/postgres-record-index-idempotency-oracle.test.js b/reference-implementation/test/postgres-record-index-idempotency-oracle.test.js index 9c0016a73..fac85b346 100644 --- a/reference-implementation/test/postgres-record-index-idempotency-oracle.test.js +++ b/reference-implementation/test/postgres-record-index-idempotency-oracle.test.js @@ -4,15 +4,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import pg from 'pg'; - import { closePostgresStorage, getPostgresPool, initPostgresStorage, } from '../server/postgres-storage.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; -const { Pool } = pg; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; let tempCounter = 0; @@ -21,48 +19,11 @@ function tempDbName() { return `pdpp_record_index_idem_${process.pid}_${tempCounter}`; } -function adminUrl(url) { - const u = new URL(url); - u.pathname = '/postgres'; - return u.toString(); -} - -function dbUrl(url, dbName) { - const u = new URL(url); - u.pathname = `/${dbName}`; - return u.toString(); -} - async function withTempDb(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); - const name = tempDbName(); - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - } catch (err) { - await admin.end(); - throw err; - } - const url = dbUrl(POSTGRES_URL, name); - try { - await fn(url); - } finally { - try { - await closePostgresStorage(); - } catch {} - try { - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ); - } catch {} - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - } catch {} - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: tempDbName(), closeConnections: closePostgresStorage }, + fn, + ); } async function readIndex(pool, indexName) { diff --git a/reference-implementation/test/postgres-record-index-repair-oracle.test.js b/reference-implementation/test/postgres-record-index-repair-oracle.test.js index 2fc820b6d..aa34d4872 100644 --- a/reference-implementation/test/postgres-record-index-repair-oracle.test.js +++ b/reference-implementation/test/postgres-record-index-repair-oracle.test.js @@ -4,15 +4,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import pg from 'pg'; - import { closePostgresStorage, getPostgresPool, initPostgresStorage, } from '../server/postgres-storage.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; -const { Pool } = pg; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; let tempCounter = 0; @@ -21,48 +19,11 @@ function tempDbName() { return `pdpp_record_index_repair_${process.pid}_${tempCounter}`; } -function adminUrl(url) { - const u = new URL(url); - u.pathname = '/postgres'; - return u.toString(); -} - -function dbUrl(url, dbName) { - const u = new URL(url); - u.pathname = `/${dbName}`; - return u.toString(); -} - async function withTempDb(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); - const name = tempDbName(); - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - } catch (err) { - await admin.end(); - throw err; - } - const url = dbUrl(POSTGRES_URL, name); - try { - await fn(url); - } finally { - try { - await closePostgresStorage(); - } catch {} - try { - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ); - } catch {} - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - } catch {} - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: tempDbName(), closeConnections: closePostgresStorage }, + fn, + ); } async function readIndex(pool, indexName) { diff --git a/reference-implementation/test/postgres-temp-database-helper.test.js b/reference-implementation/test/postgres-temp-database-helper.test.js new file mode 100644 index 000000000..b872f75ab --- /dev/null +++ b/reference-implementation/test/postgres-temp-database-helper.test.js @@ -0,0 +1,121 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import pg from 'pg'; + +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; + +const { Pool } = pg; +const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; + +function adminUrl(connectionString) { + const url = new URL(connectionString); + url.pathname = '/postgres'; + return url.toString(); +} + +async function assertDatabaseDropped(databaseName) { + const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); + try { + const result = await admin.query( + 'SELECT 1 FROM pg_database WHERE datname = $1', + [databaseName], + ); + assert.equal(result.rowCount, 0); + } finally { + await admin.end(); + } +} + +function databaseName(suffix) { + return `pdpp_temp_helper_${suffix}_${process.pid}_${Date.now()}`; +} + +if (!POSTGRES_URL) { + test('temporary Postgres database helper (skipped: PDPP_TEST_POSTGRES_URL unset)', { skip: true }, () => {}); +} else { + test('temporary Postgres database helper closes connections before dropping the database', async () => { + const temporaryDatabase = databaseName('close'); + let temporaryPool; + let closed = false; + + await withTemporaryPostgresDatabase( + { + connectionString: POSTGRES_URL, + databaseName: temporaryDatabase, + closeConnections: async () => { + await temporaryPool.end(); + closed = true; + }, + }, + async (connectionString) => { + temporaryPool = new Pool({ connectionString }); + const result = await temporaryPool.query('SELECT current_database() AS database_name'); + assert.equal(result.rows[0]?.database_name, temporaryDatabase); + }, + ); + + assert.equal(closed, true); + + await assertDatabaseDropped(temporaryDatabase); + }); + + test('temporary Postgres database helper preserves a callback failure', async () => { + const temporaryDatabase = databaseName('callback'); + const callbackError = new Error('callback failed'); + + await assert.rejects( + withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: temporaryDatabase }, + async () => { throw callbackError; }, + ), + (error) => error === callbackError, + ); + + await assertDatabaseDropped(temporaryDatabase); + }); + + test('temporary Postgres database helper reports a cleanup failure after dropping the database', async () => { + const temporaryDatabase = databaseName('cleanup'); + const cleanupError = new Error('cleanup failed'); + + await assert.rejects( + withTemporaryPostgresDatabase( + { + connectionString: POSTGRES_URL, + databaseName: temporaryDatabase, + closeConnections: async () => { throw cleanupError; }, + }, + async () => {}, + ), + (error) => error === cleanupError, + ); + + await assertDatabaseDropped(temporaryDatabase); + }); + + test('temporary Postgres database helper preserves callback and cleanup failures', async () => { + const temporaryDatabase = databaseName('combined'); + const callbackError = new Error('callback failed'); + const cleanupError = new Error('cleanup failed'); + + await assert.rejects( + withTemporaryPostgresDatabase( + { + connectionString: POSTGRES_URL, + databaseName: temporaryDatabase, + closeConnections: async () => { throw cleanupError; }, + }, + async () => { throw callbackError; }, + ), + (error) => error instanceof AggregateError + && error.errors.length === 2 + && error.errors[0] === callbackError + && error.errors[1] === cleanupError, + ); + + await assertDatabaseDropped(temporaryDatabase); + }); +} diff --git a/reference-implementation/test/spine-source-boot-backfill.test.js b/reference-implementation/test/spine-source-boot-backfill.test.js index 1f879bb53..675bb3ce4 100644 --- a/reference-implementation/test/spine-source-boot-backfill.test.js +++ b/reference-implementation/test/spine-source-boot-backfill.test.js @@ -18,8 +18,6 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; -import pg from 'pg'; - import { closeDb, getDb, initDb } from '../server/db.js'; import { initPostgresStorage, @@ -27,8 +25,8 @@ import { getPostgresPool, } from '../server/postgres-storage.js'; import { mergeEventRows, postgresListSpineCorrelations } from '../lib/postgres-spine.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; -const { Pool } = pg; const POSTGRES_URL = process.env.PDPP_TEST_POSTGRES_URL; // A deterministic temp DB name per file run. Date.now/Math.random are fine in @@ -39,47 +37,11 @@ function tempDbName() { return `pdpp_spine_boot_${process.pid}_${tempCounter}`; } -function adminUrl(url) { - const u = new URL(url); - u.pathname = '/postgres'; - return u.toString(); -} - -function dbUrl(url, dbName) { - const u = new URL(url); - u.pathname = `/${dbName}`; - return u.toString(); -} - async function withTempDb(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); - const name = tempDbName(); - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - } catch (err) { - await admin.end(); - throw err; - } - const url = dbUrl(POSTGRES_URL, name); - try { - await fn(url); - } finally { - try { - await closePostgresStorage(); - } catch {} - // Terminate stragglers, then drop. - try { - await admin.query( - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ); - } catch {} - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - } catch {} - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: tempDbName(), closeConnections: closePostgresStorage }, + fn, + ); } // Insert a minimal spine_events row with NULL source columns. `actorType` diff --git a/reference-implementation/test/storage-mode-startup-boundary.test.js b/reference-implementation/test/storage-mode-startup-boundary.test.js index aaf8d7ef4..a4d997e5e 100644 --- a/reference-implementation/test/storage-mode-startup-boundary.test.js +++ b/reference-implementation/test/storage-mode-startup-boundary.test.js @@ -31,14 +31,13 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import pg from 'pg'; - import { getRegisteredClient } from '../server/auth.js'; import { closeDb } from '../server/db.js'; import { closePostgresStorage, getStorageBackendKind, } from '../server/postgres-storage.js'; +import { withTemporaryPostgresDatabase } from './helpers/postgres-temp-database.js'; import { startServer } from '../server/index.js'; const SEED_CLIENT = { @@ -48,7 +47,6 @@ const SEED_CLIENT = { token_endpoint_auth_method: 'none', }; -const { Pool } = pg; let tempPostgresCounter = 0; function tempPostgresDbName() { @@ -56,42 +54,11 @@ function tempPostgresDbName() { return `pdpp_storage_boundary_${process.pid}_${tempPostgresCounter}`; } -function adminUrl(url) { - const u = new URL(url); - u.pathname = '/postgres'; - return u.toString(); -} - -function dbUrl(url, dbName) { - const u = new URL(url); - u.pathname = `/${dbName}`; - return u.toString(); -} - async function withTempPostgresDb(fn) { - const admin = new Pool({ connectionString: adminUrl(POSTGRES_URL) }); - const name = tempPostgresDbName(); - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - await admin.query(`CREATE DATABASE "${name}"`); - await fn(dbUrl(POSTGRES_URL, name)); - } finally { - try { - await closePostgresStorage(); - } catch {} - try { - await admin.query( - `SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = $1 AND pid <> pg_backend_pid()`, - [name], - ); - } catch {} - try { - await admin.query(`DROP DATABASE IF EXISTS "${name}"`); - } catch {} - await admin.end(); - } + return withTemporaryPostgresDatabase( + { connectionString: POSTGRES_URL, databaseName: tempPostgresDbName(), closeConnections: closePostgresStorage }, + fn, + ); } async function closeStartedServer(server) { diff --git a/scripts/docker-neko-dynamic-allocator-smoke-config.mjs b/scripts/docker-neko-dynamic-allocator-smoke-config.mjs new file mode 100644 index 000000000..6ec6eb4da --- /dev/null +++ b/scripts/docker-neko-dynamic-allocator-smoke-config.mjs @@ -0,0 +1,44 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +const DEPLOYMENT_LABEL = 'org.pdpp.reference.neko.deployment_id'; +const SURFACE_LABEL = 'org.pdpp.reference.neko.surface_id'; + +function runScoped(base, runId) { + if (!base || /[\r\n]/.test(base)) throw new Error('dynamic n.eko smoke identity bases must be non-empty single-line strings'); + return `${base.replace(/-+$/, '')}-${runId}`; +} + +export function deriveDynamicNekoSmokeConfig(env, runId) { + if (!/^[a-z0-9-]+$/.test(runId)) throw new Error('dynamic n.eko smoke run id must contain only lowercase letters, digits, and hyphens'); + if (env.PDPP_NEKO_WEBRTC_HOST_PORT_START || env.PDPP_NEKO_WEBRTC_HOST_PORT_END) { + throw new Error( + 'PDPP_NEKO_WEBRTC_HOST_PORT_START and PDPP_NEKO_WEBRTC_HOST_PORT_END must be unset: the smoke reserves a per-run port pair', + ); + } + + const projectName = runScoped(env.PDPP_NEKO_DYNAMIC_SMOKE_PROJECT_NAME ?? 'pdppdynsmoke', runId); + const profileRoot = runScoped(env.PDPP_NEKO_PROFILE_STORAGE_ROOT ?? '/tmp/pdpp-neko-profiles-smoke', runId); + const deploymentId = env.PDPP_NEKO_DEPLOYMENT_ID + ? runScoped(env.PDPP_NEKO_DEPLOYMENT_ID, runId) + : projectName; + const surfaceA = `dynamic-smoke-${runId}-a`; + const surfaceB = `dynamic-smoke-${runId}-b`; + + return { + projectName, + profileRoot, + deploymentId, + surfaceA, + surfaceB, + cleanupFilters: (surfaceId) => [ + `label=${DEPLOYMENT_LABEL}=${deploymentId}`, + `label=${SURFACE_LABEL}=${surfaceId}`, + ], + }; +} + +if (import.meta.main) { + const config = deriveDynamicNekoSmokeConfig(process.env, process.argv[2]); + process.stdout.write([config.projectName, config.profileRoot, config.deploymentId, config.surfaceA, config.surfaceB].join('\n')); +} diff --git a/scripts/docker-neko-dynamic-allocator-smoke.sh b/scripts/docker-neko-dynamic-allocator-smoke.sh index 778d1e906..683378d4d 100755 --- a/scripts/docker-neko-dynamic-allocator-smoke.sh +++ b/scripts/docker-neko-dynamic-allocator-smoke.sh @@ -9,20 +9,53 @@ if [[ "${PDPP_DOCKER_DYNAMIC_NEKO_ALLOCATOR_SMOKE:-}" != "1" ]]; then exit 0 fi -PROJECT_NAME="${COMPOSE_PROJECT_NAME:-pdppdynsmoke}" -PROFILE_ROOT="${PDPP_NEKO_PROFILE_STORAGE_ROOT:-/tmp/pdpp-neko-profiles-smoke}" -HOST_PORT_START="${PDPP_NEKO_WEBRTC_HOST_PORT_START:-59101}" -HOST_PORT_END="${PDPP_NEKO_WEBRTC_HOST_PORT_END:-59102}" -LABEL_OWNER="org.pdpp.reference.neko.owner=pdpp-reference" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SMOKE_RUN_ID_BASE="${PDPP_NEKO_DYNAMIC_SMOKE_RUN_ID:-smoke}" +SMOKE_RUN_ID="${SMOKE_RUN_ID_BASE}-$(node -e "console.log(require('node:crypto').randomBytes(6).toString('hex'))")" +SMOKE_CONFIG_OUTPUT="$(node "$SCRIPT_DIR/docker-neko-dynamic-allocator-smoke-config.mjs" "$SMOKE_RUN_ID")" +mapfile -t SMOKE_CONFIG <<< "$SMOKE_CONFIG_OUTPUT" +if (( ${#SMOKE_CONFIG[@]} != 5 )); then + echo "dynamic n.eko allocator Docker smoke failed: invalid derived configuration" >&2 + exit 1 +fi +PROJECT_NAME="${SMOKE_CONFIG[0]}" +PROFILE_ROOT="${SMOKE_CONFIG[1]}" +DEPLOYMENT_ID="${SMOKE_CONFIG[2]}" +SMOKE_SURFACE_A="${SMOKE_CONFIG[3]}" +SMOKE_SURFACE_B="${SMOKE_CONFIG[4]}" +DEPLOYMENT_LABEL="org.pdpp.reference.neko.deployment_id" SURFACE_LABEL="org.pdpp.reference.neko.surface_id" -SMOKE_SURFACE_A="dynamic-smoke-surface-a" -SMOKE_SURFACE_B="dynamic-smoke-surface-b" + +allocate_default_port_range() { + local port + for ((port = 55000; port <= 64998; port += 2)); do + if ! ss -H -ltn "sport = :$port" | grep -q . \ + && ! ss -H -lun "sport = :$port" | grep -q . \ + && ! ss -H -ltn "sport = :$((port + 1))" | grep -q . \ + && ! ss -H -lun "sport = :$((port + 1))" | grep -q .; then + HOST_PORT_START="$port" + HOST_PORT_END="$((port + 1))" + return + fi + done + echo "dynamic n.eko allocator Docker smoke failed: no free consecutive WebRTC ports" >&2 + exit 1 +} + +command -v ss >/dev/null 2>&1 || { echo "dynamic n.eko allocator Docker smoke requires ss" >&2; exit 1; } +command -v flock >/dev/null 2>&1 || { echo "dynamic n.eko allocator Docker smoke requires flock" >&2; exit 1; } +exec {PORT_LOCK_FD}>"${PDPP_NEKO_DYNAMIC_SMOKE_PORT_LOCK_FILE:-/tmp/pdpp-neko-dynamic-smoke-ports.lock}" +flock "$PORT_LOCK_FD" +allocate_default_port_range export COMPOSE_PROJECT_NAME="$PROJECT_NAME" export PDPP_NEKO_PROFILE_STORAGE_ROOT="$PROFILE_ROOT" export PDPP_NEKO_WEBRTC_HOST_PORT_START="$HOST_PORT_START" export PDPP_NEKO_WEBRTC_HOST_PORT_END="$HOST_PORT_END" +export PDPP_NEKO_DEPLOYMENT_ID="$DEPLOYMENT_ID" export PDPP_NEKO_ALLOCATOR_PORT="${PDPP_NEKO_ALLOCATOR_PORT:-7331}" +export PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_A="$SMOKE_SURFACE_A" +export PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_B="$SMOKE_SURFACE_B" export NEKO_PASSWORD="${NEKO_PASSWORD:-pdpp-dynamic-smoke}" export NEKO_USERNAME="${NEKO_USERNAME:-operator}" @@ -32,12 +65,12 @@ cleanup() { set +e local surface ids for surface in "$SMOKE_SURFACE_A" "$SMOKE_SURFACE_B"; do - ids="$(docker ps -aq --filter "label=$LABEL_OWNER" --filter "label=$SURFACE_LABEL=$surface" 2>/dev/null || true)" + ids="$(docker ps -aq --filter "label=$DEPLOYMENT_LABEL=$DEPLOYMENT_ID" --filter "label=$SURFACE_LABEL=$surface" 2>/dev/null || true)" if [[ -n "$ids" ]]; then docker rm -f $ids >/dev/null 2>&1 || true fi done - "${DC[@]}" down --remove-orphans >/dev/null 2>&1 || true + "${DC[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true } trap cleanup EXIT @@ -79,14 +112,14 @@ import assert from "node:assert/strict"; const baseUrl = `http://127.0.0.1:${process.env.PDPP_NEKO_ALLOCATOR_PORT ?? "7331"}`; const surfaces = [ { - surface_id: "dynamic-smoke-surface-a", + surface_id: process.env.PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_A, connector_id: "chatgpt", - profile_key: "pdpp-smoke://profile/a", + profile_key: `pdpp-smoke://${process.env.PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_A}`, }, { - surface_id: "dynamic-smoke-surface-b", + surface_id: process.env.PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_B, connector_id: "chatgpt", - profile_key: "pdpp-smoke://profile/b", + profile_key: `pdpp-smoke://${process.env.PDPP_NEKO_DYNAMIC_SMOKE_SURFACE_B}`, }, ]; @@ -129,7 +162,7 @@ assert.equal(new Set(allocated.map((surface) => surface.allocator_metadata.conta assert.equal(new Set(allocated.map((surface) => surface.allocator_metadata.host_port)).size, 2); assert.deepEqual( allocated.map((surface) => surface.allocator_metadata.host_port).sort(), - ["59101", "59102"], + [process.env.PDPP_NEKO_WEBRTC_HOST_PORT_START, process.env.PDPP_NEKO_WEBRTC_HOST_PORT_END].sort(), ); assert.equal(new Set(allocated.map((surface) => surface.allocator_metadata.profile_path)).size, 2); for (const surface of allocated) { @@ -178,17 +211,17 @@ cleanup trap - EXIT for surface in "$SMOKE_SURFACE_A" "$SMOKE_SURFACE_B"; do - if [[ -n "$(docker ps -aq --filter "label=$LABEL_OWNER" --filter "label=$SURFACE_LABEL=$surface")" ]]; then + if [[ -n "$(docker ps -aq --filter "label=$DEPLOYMENT_LABEL=$DEPLOYMENT_ID" --filter "label=$SURFACE_LABEL=$surface")" ]]; then fail "labeled dynamic n.eko smoke container $surface remains after cleanup" fi done -if docker network ls --filter "label=$LABEL_OWNER" --format '{{.Name}}' | grep -q .; then - fail "labeled dynamic n.eko networks remain after cleanup" -fi - if docker network ls --filter "label=com.docker.compose.project=$PROJECT_NAME" --format '{{.Name}}' | grep -q .; then fail "Compose network for $PROJECT_NAME remains after cleanup" fi +if docker volume ls --filter "label=com.docker.compose.project=$PROJECT_NAME" --format '{{.Name}}' | grep -q .; then + fail "Compose volumes for $PROJECT_NAME remain after cleanup" +fi + echo "Dynamic n.eko allocator Docker smoke passed for project $PROJECT_NAME." diff --git a/scripts/docker-neko-dynamic-allocator-smoke.test.mjs b/scripts/docker-neko-dynamic-allocator-smoke.test.mjs new file mode 100644 index 000000000..d2584ccbc --- /dev/null +++ b/scripts/docker-neko-dynamic-allocator-smoke.test.mjs @@ -0,0 +1,38 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { deriveDynamicNekoSmokeConfig } from './docker-neko-dynamic-allocator-smoke-config.mjs'; + +test('dynamic n.eko smoke derives disjoint resources from one inherited environment', () => { + const inherited = { + PDPP_NEKO_DYNAMIC_SMOKE_PROJECT_NAME: 'shared-project', + PDPP_NEKO_PROFILE_STORAGE_ROOT: '/shared/profiles', + PDPP_NEKO_DEPLOYMENT_ID: 'shared-deployment', + }; + const first = deriveDynamicNekoSmokeConfig(inherited, 'a1b2c3'); + const second = deriveDynamicNekoSmokeConfig(inherited, 'd4e5f6'); + + for (const field of ['projectName', 'profileRoot', 'deploymentId', 'surfaceA', 'surfaceB']) { + assert.notEqual(first[field], second[field]); + } + assert.deepEqual(first.cleanupFilters(first.surfaceA), [ + 'label=org.pdpp.reference.neko.deployment_id=shared-deployment-a1b2c3', + 'label=org.pdpp.reference.neko.surface_id=dynamic-smoke-a1b2c3-a', + ]); + assert.notDeepEqual(first.cleanupFilters(first.surfaceA), second.cleanupFilters(second.surfaceA)); +}); + +test('dynamic n.eko smoke rejects inherited WebRTC port settings', () => { + assert.throws( + () => deriveDynamicNekoSmokeConfig({ PDPP_NEKO_WEBRTC_HOST_PORT_START: '59101' }, 'a1b2c3'), + /must be unset/, + ); +}); + +test('dynamic n.eko smoke defaults deployment id to its derived project id', () => { + const config = deriveDynamicNekoSmokeConfig({}, 'smoke-a1b2c3'); + assert.equal(config.deploymentId, config.projectName); +}); diff --git a/scripts/docker-smoke.sh b/scripts/docker-smoke.sh index 7f69b202b..21fbd30bc 100755 --- a/scripts/docker-smoke.sh +++ b/scripts/docker-smoke.sh @@ -4,7 +4,7 @@ set -euo pipefail -PROJECT_NAME="${COMPOSE_PROJECT_NAME:-pdpp-docker-smoke}" +PROJECT_NAME="${PDPP_DOCKER_SMOKE_PROJECT_NAME:-pdpp-docker-smoke}" ORIGIN="${PDPP_REFERENCE_ORIGIN:-http://localhost:3002}" OWNER_PASSWORD="${PDPP_OWNER_PASSWORD:-$(node -e "console.log(require('node:crypto').randomBytes(24).toString('base64url'))")}" @@ -15,7 +15,7 @@ export PDPP_DB_PATH="${PDPP_DB_PATH:-/tmp/pdpp-smoke.sqlite}" export PDPP_EMBEDDING_DOWNLOAD_ALLOWED="${PDPP_EMBEDDING_DOWNLOAD_ALLOWED:-0}" cleanup() { - docker compose down --remove-orphans >/dev/null + docker compose down --volumes --remove-orphans >/dev/null } trap cleanup EXIT diff --git a/scripts/railway-sqlite-restart-smoke.sh b/scripts/railway-sqlite-restart-smoke.sh index 5b3724f03..4b0e9ab77 100755 --- a/scripts/railway-sqlite-restart-smoke.sh +++ b/scripts/railway-sqlite-restart-smoke.sh @@ -47,7 +47,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -PROJECT_NAME="${COMPOSE_PROJECT_NAME:-pdpp-sqlite-restart-smoke}" +PROJECT_NAME="${PDPP_RAILWAY_SQLITE_SMOKE_PROJECT_NAME:-pdpp-sqlite-restart-smoke}" ORIGIN="${PDPP_REFERENCE_ORIGIN:-http://localhost:3002}" OWNER_PASSWORD="${PDPP_OWNER_PASSWORD:-$(node -e "console.log(require('node:crypto').randomBytes(24).toString('base64url'))")}" @@ -78,7 +78,7 @@ NODE cd "$REPO_ROOT" cleanup() { - docker compose down --remove-orphans >/dev/null 2>&1 || true + docker compose down --volumes --remove-orphans >/dev/null 2>&1 || true } trap cleanup EXIT