diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md index a46fb3b4b84..ec71eb833f5 100644 --- a/.github/WORKFLOWS.md +++ b/.github/WORKFLOWS.md @@ -520,6 +520,7 @@ Scripts in `.github/scripts/`: |-------------------------|-------------------|---------------------------| | `validate-docs-links.js`| Check doc URLs | `util-check-docs-urls.yml`| | `send-build-stats.mjs` | Build telemetry | `setup-nodejs` action | +| `db-test-matrix.mjs` | DB test matrix from `postgres-versions.json` | `ci-pull-requests.yml` | ### Slack Scripts diff --git a/.github/docker-compose.yml b/.github/docker-compose.yml index d741199d221..48cac0a073f 100644 --- a/.github/docker-compose.yml +++ b/.github/docker-compose.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:16 + image: ${TEST_IMAGE_POSTGRES:?set it from packages/testing/containers/postgres-versions.json} restart: always environment: - POSTGRES_DB=n8n @@ -8,5 +8,7 @@ services: - POSTGRES_PASSWORD=password ports: - 5432:5432 + # Not PGDATA: Postgres 18+ moved it under /var/lib/postgresql// and + # refuses to start when /var/lib/postgresql/data is mounted. tmpfs: - - /var/lib/postgresql/data + - /var/lib/postgresql diff --git a/.github/scripts/db-test-matrix.mjs b/.github/scripts/db-test-matrix.mjs new file mode 100644 index 00000000000..39a2a4538a0 --- /dev/null +++ b/.github/scripts/db-test-matrix.mjs @@ -0,0 +1,95 @@ +// Builds the job matrix for test-db-reusable.yml. Node builtins only: it runs in a +// job that installs the monorepo's dependencies, not this folder's. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +export const POSTGRES_VERSIONS_PATH = 'packages/testing/containers/postgres-versions.json'; + +/** The matrix wall is bounded by its slowest leg, so a larger runner buys nothing. */ +const RUNNER = 'blacksmith-4vcpu-ubuntu-2204'; + +/** + * @typedef {Object} PostgresVersions + * @property {string} primary + * @property {Array<{ major: number, image: string, support?: string }>} matrix + */ + +/** @returns {PostgresVersions} */ +export function readPostgresVersions(repoRoot = REPO_ROOT) { + const file = path.join(repoRoot, POSTGRES_VERSIONS_PATH); + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +/** + * Coverage and the schema-docs check run on the primary Postgres leg only, since + * the committed docs come from that one version. + * + * @param {PostgresVersions} versions + */ +export function buildMatrix(versions) { + const { primary, matrix } = versions; + + if (!Array.isArray(matrix) || matrix.length === 0) { + throw new Error(`${POSTGRES_VERSIONS_PATH}: "matrix" must be a non-empty array`); + } + + // Checked first: everything below treats the last entry as the newest. + const majors = matrix.map((entry) => entry.major); + for (let i = 1; i < majors.length; i++) { + if (majors[i] <= majors[i - 1]) { + throw new Error( + `${POSTGRES_VERSIONS_PATH}: "matrix" must be sorted by ascending major, got ${majors.join(', ')}`, + ); + } + } + + const newest = matrix[matrix.length - 1]; + if (newest.image !== primary) { + throw new Error( + `${POSTGRES_VERSIONS_PATH}: "primary" (${primary}) must be the newest "matrix" entry's image (${newest.image})`, + ); + } + + // An exact minor keeps CI reproducible across Postgres point releases. + for (const { major, image } of matrix) { + const pinned = /^postgres:(\d+)\.\d+-alpine$/.exec(image); + if (!pinned) { + throw new Error( + `${POSTGRES_VERSIONS_PATH}: "${image}" must pin an exact minor, e.g. postgres:${major}.4-alpine`, + ); + } + if (Number(pinned[1]) !== major) { + throw new Error(`${POSTGRES_VERSIONS_PATH}: "${image}" does not match major ${major}`); + } + } + + return [ + { + name: 'SQLite Pooled', + runner: RUNNER, + 'test-cmd': 'pnpm test:sqlite', + 'migration-cmd': 'pnpm test:sqlite:migrations', + 'schema-check-cmd': 'pnpm --filter=@n8n/db schema:check:sqlite', + TEST_IMAGE_POSTGRES: undefined, + collectCoverage: 'false', + }, + ...matrix.map(({ major, image }) => ({ + name: `Postgres ${major}`, + runner: RUNNER, + 'test-cmd': 'pnpm test:postgres:integration:tc', + 'migration-cmd': 'pnpm test:postgres:migrations:tc', + 'schema-check-cmd': image === primary ? 'pnpm --filter=@n8n/db schema:check:postgres' : '', + TEST_IMAGE_POSTGRES: image, + collectCoverage: image === primary ? 'true' : 'false', + })), + ]; +} + +// Skipped when imported by the tests. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + console.log(JSON.stringify(buildMatrix(readPostgresVersions()))); +} diff --git a/.github/scripts/db-test-matrix.test.mjs b/.github/scripts/db-test-matrix.test.mjs new file mode 100644 index 00000000000..c7dab805985 --- /dev/null +++ b/.github/scripts/db-test-matrix.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { buildMatrix, readPostgresVersions, POSTGRES_VERSIONS_PATH } from './db-test-matrix.mjs'; + +// node --test ./.github/scripts/db-test-matrix.test.mjs + +const versions = () => ({ + primary: 'postgres:18.4-alpine', + matrix: [ + { major: 16, support: 'compatibility', image: 'postgres:16.14-alpine' }, + { major: 17, support: 'supported', image: 'postgres:17.10-alpine' }, + { major: 18, support: 'supported', image: 'postgres:18.4-alpine' }, + ], +}); + +describe('postgres-versions.json', () => { + it('is valid, so the DB workflow can build a matrix from it', () => { + assert.doesNotThrow(() => buildMatrix(readPostgresVersions())); + }); + + it('covers the supported range plus one compatibility major', () => { + const { matrix } = readPostgresVersions(); + + const supported = matrix.filter((entry) => entry.support === 'supported'); + const compatibility = matrix.filter((entry) => entry.support === 'compatibility'); + + assert.equal(supported.length, 2, `${POSTGRES_VERSIONS_PATH}: expected two supported majors`); + assert.equal( + compatibility.length, + 1, + `${POSTGRES_VERSIONS_PATH}: expected one compatibility major`, + ); + assert.ok(compatibility[0].major < Math.min(...supported.map((entry) => entry.major))); + }); +}); + +describe('buildMatrix', () => { + it('emits one leg per database', () => { + const legs = buildMatrix(versions()); + + assert.deepEqual( + legs.map((leg) => leg.name), + ['SQLite Pooled', 'Postgres 16', 'Postgres 17', 'Postgres 18'], + ); + }); + + it('passes each Postgres leg its own pinned image', () => { + const legs = buildMatrix(versions()); + + assert.deepEqual( + legs.filter((leg) => leg.TEST_IMAGE_POSTGRES).map((leg) => leg.TEST_IMAGE_POSTGRES), + ['postgres:16.14-alpine', 'postgres:17.10-alpine', 'postgres:18.4-alpine'], + ); + assert.equal(legs[0].TEST_IMAGE_POSTGRES, undefined, 'SQLite leg needs no Postgres image'); + }); + + it('runs every leg against both the integration and the migration suite', () => { + for (const leg of buildMatrix(versions())) { + assert.ok(leg['test-cmd'], `${leg.name} has no test command`); + assert.ok(leg['migration-cmd'], `${leg.name} has no migration command`); + } + }); + + it('collects coverage and checks schema docs on the primary Postgres leg only', () => { + const legs = buildMatrix(versions()); + + const collecting = legs.filter((leg) => leg.collectCoverage === 'true'); + assert.deepEqual( + collecting.map((leg) => leg.name), + ['Postgres 18'], + ); + + const checkingPostgresSchema = legs.filter( + (leg) => leg['schema-check-cmd'] === 'pnpm --filter=@n8n/db schema:check:postgres', + ); + assert.deepEqual( + checkingPostgresSchema.map((leg) => leg.name), + ['Postgres 18'], + ); + const skipping = legs.filter((leg) => leg['schema-check-cmd'] === ''); + assert.deepEqual( + skipping.map((leg) => leg.name), + ['Postgres 16', 'Postgres 17'], + ); + }); + + it('still checks the SQLite schema docs', () => { + const legs = buildMatrix(versions()); + + assert.equal(legs[0]['schema-check-cmd'], 'pnpm --filter=@n8n/db schema:check:sqlite'); + }); + + it('rejects a primary that is not the newest major', () => { + const stale = { ...versions(), primary: 'postgres:17.10-alpine' }; + + assert.throws(() => buildMatrix(stale), /"primary".*must be the newest/); + }); + + it('rejects majors that are not in ascending order', () => { + const unsorted = versions(); + [unsorted.matrix[0], unsorted.matrix[1]] = [unsorted.matrix[1], unsorted.matrix[0]]; + + assert.throws(() => buildMatrix(unsorted), /ascending major, got 17, 16, 18/); + }); + + it('rejects a floating image tag', () => { + const floating = { + primary: 'postgres:18-alpine', + matrix: [{ major: 18, image: 'postgres:18-alpine' }], + }; + + assert.throws(() => buildMatrix(floating), /must pin an exact minor/); + }); + + it('rejects an image whose major disagrees with its entry', () => { + const mismatched = { + primary: 'postgres:17.10-alpine', + matrix: [{ major: 18, image: 'postgres:17.10-alpine' }], + }; + + assert.throws(() => buildMatrix(mismatched), /does not match major 18/); + }); + + it('rejects an empty matrix', () => { + assert.throws(() => buildMatrix({ primary: 'postgres:18.4-alpine', matrix: [] }), /non-empty/); + }); +}); diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index 282fbded6da..ac75a0dcc02 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -33,6 +33,7 @@ jobs: commit_sha: ${{ steps.commit-sha.outputs.sha }} merge_base: ${{ steps.ci-filter.outputs.merge-base }} matrix: ${{ steps.generate-matrix.outputs.matrix }} + db_test_matrix: ${{ steps.generate-db-test-matrix.outputs.db_test_matrix }} skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }} affected_packages: ${{ steps.affected-packages.outputs.list }} changed_files: ${{ steps.ci-filter.outputs.changed-files }} @@ -144,7 +145,10 @@ jobs: packages/@n8n/scheduler/src/** packages/cli/**/__tests__/** packages/testing/containers/services/postgres.ts + packages/testing/containers/postgres-versions.json + packages/testing/containers/test-containers.ts .github/workflows/test-db-reusable.yml + .github/scripts/db-test-matrix.mjs docs/generated/** .tbls.sqlite.yml .tbls.postgres.yml @@ -175,6 +179,12 @@ jobs: echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT" + # Generated here because a reusable workflow cannot resolve its own matrix. + - name: Generate DB test matrix + id: generate-db-test-matrix + if: fromJSON(steps.ci-filter.outputs.results).db + run: echo "db_test_matrix=$(node .github/scripts/db-test-matrix.mjs)" >> "$GITHUB_OUTPUT" + - name: Compute affected packages id: affected-packages if: fromJSON(steps.ci-filter.outputs.results).unit @@ -322,6 +332,7 @@ jobs: uses: ./.github/workflows/test-db-reusable.yml with: ref: ${{ needs.install-and-build.outputs.commit_sha }} + matrix: ${{ needs.install-and-build.outputs.db_test_matrix }} performance: name: Performance diff --git a/.github/workflows/test-db-reusable.yml b/.github/workflows/test-db-reusable.yml index 86987fa7b51..e51c6555d94 100644 --- a/.github/workflows/test-db-reusable.yml +++ b/.github/workflows/test-db-reusable.yml @@ -7,6 +7,10 @@ on: required: false type: string default: '' + matrix: + description: 'Job matrix JSON, from .github/scripts/db-test-matrix.mjs.' + required: true + type: string env: NODE_OPTIONS: '--max-old-space-size=6144' @@ -19,24 +23,7 @@ jobs: strategy: fail-fast: false matrix: - include: - - name: SQLite Pooled - runner: blacksmith-4vcpu-ubuntu-2204 - test-cmd: pnpm test:sqlite - migration-cmd: pnpm test:sqlite:migrations - schema-check-cmd: pnpm --filter=@n8n/db schema:check:sqlite - collectCoverage: 'false' - - name: Postgres 16 - # 4vcpu is enough since persistent-worker + template-DB make PG16 - # complete in ~480s, which is now close to the SQLite leg (~360s) - # — the matrix wall is bounded by the slower leg, so the larger - # 8vcpu runner buys headroom we can't cash in anywhere. - runner: blacksmith-4vcpu-ubuntu-2204 - test-cmd: pnpm test:postgres:integration:tc - migration-cmd: pnpm test:postgres:migrations:tc - schema-check-cmd: pnpm --filter=@n8n/db schema:check:postgres - TEST_IMAGE_POSTGRES: 'postgres:16' - collectCoverage: 'true' + include: ${{ fromJSON(inputs.matrix) }} env: TEST_IMAGE_POSTGRES: ${{ matrix.TEST_IMAGE_POSTGRES }} COVERAGE_ENABLED: ${{ matrix.collectCoverage }} @@ -64,6 +51,7 @@ jobs: # postgres testcontainer) and fails if they differ from what's committed. # tbls runs as a Docker image in CI. - name: Verify schema docs are up to date + if: matrix.schema-check-cmd != '' run: ${{ matrix.schema-check-cmd }} - name: Upload coverage to Codecov diff --git a/.github/workflows/util-data-tooling.yml b/.github/workflows/util-data-tooling.yml index ebe8c29b5e8..e56ad34f0e0 100644 --- a/.github/workflows/util-data-tooling.yml +++ b/.github/workflows/util-data-tooling.yml @@ -68,6 +68,12 @@ jobs: if: fromJSON(steps.paths-filter.outputs.results).db uses: ./.github/actions/setup-nodejs + - name: Resolve Postgres image + if: fromJSON(steps.paths-filter.outputs.results).db + run: | + IMAGE=$(node -p "require('./packages/testing/containers/postgres-versions.json').primary") + echo "TEST_IMAGE_POSTGRES=$IMAGE" >> "$GITHUB_ENV" + - name: Start Postgres if: fromJSON(steps.paths-filter.outputs.results).db uses: isbang/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 diff --git a/packages/@n8n/db/package.json b/packages/@n8n/db/package.json index b9e45ab268b..a54f52cc361 100644 --- a/packages/@n8n/db/package.json +++ b/packages/@n8n/db/package.json @@ -60,6 +60,7 @@ "@testcontainers/postgresql": "catalog:", "@vitest/coverage-v8": "catalog:", "express": "catalog:", + "n8n-containers": "workspace:*", "testcontainers": "catalog:", "@typescript/native": "catalog:typescript-tooling", "typescript": "catalog:typescript-tooling", diff --git a/packages/@n8n/db/scripts/schema-docs.mjs b/packages/@n8n/db/scripts/schema-docs.mjs index e5c3a3444c9..6b3a2741ec8 100644 --- a/packages/@n8n/db/scripts/schema-docs.mjs +++ b/packages/@n8n/db/scripts/schema-docs.mjs @@ -18,7 +18,7 @@ * a local binary. */ import { spawn, spawnSync } from 'node:child_process'; -import { mkdirSync, rmSync } from 'node:fs'; +import { mkdirSync, readFileSync, rmSync } from 'node:fs'; import { dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -31,6 +31,15 @@ const TMP_DIR = resolve(PKG_ROOT, '.tmp-schema-docs'); const TBLS_IMAGE = 'ghcr.io/k1low/tbls:v1.94.5@sha256:ae8a3bff6d4f8495d13a7982cd71fac3e8a3d1dd394888f2c44ef82216aa14e4'; +const POSTGRES_VERSIONS_PATH = resolve( + REPO_ROOT, + 'packages/testing/containers/postgres-versions.json', +); + +function primaryPostgresImage() { + return JSON.parse(readFileSync(POSTGRES_VERSIONS_PATH, 'utf8')).primary; +} + /** Thrown for expected, user-facing failures so `main`'s cleanup still runs. */ class FailError extends Error {} @@ -122,10 +131,10 @@ async function provision(dbType) { }; } - // postgres: spin a throwaway container. Image tag matches the testcontainers - // default in `n8n-containers` (packages/testing/containers/test-containers.ts). + // postgres: spin a throwaway container on the version the rest of the test + // tooling defaults to. const { PostgreSqlContainer } = await import('@testcontainers/postgresql'); - const container = await new PostgreSqlContainer('postgres:18-alpine').start(); + const container = await new PostgreSqlContainer(primaryPostgresImage()).start(); const conn = { host: container.getHost(), port: container.getMappedPort(5432), diff --git a/packages/@n8n/db/src/connection/__tests__/db-connection-monitor.recovery.postgres.test.ts b/packages/@n8n/db/src/connection/__tests__/db-connection-monitor.recovery.postgres.test.ts index 61bcb3dc87e..392d0a22950 100644 --- a/packages/@n8n/db/src/connection/__tests__/db-connection-monitor.recovery.postgres.test.ts +++ b/packages/@n8n/db/src/connection/__tests__/db-connection-monitor.recovery.postgres.test.ts @@ -2,6 +2,7 @@ import type { Logger } from '@n8n/backend-common'; import type { DatabaseConfig } from '@n8n/config'; import { DataSource } from '@n8n/typeorm'; import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import postgresVersions from 'n8n-containers/postgres-versions.json'; import type { ErrorReporter } from 'n8n-core'; import net from 'node:net'; import { getContainerRuntimeClient } from 'testcontainers'; @@ -94,11 +95,11 @@ class FreezableProxy { * a machine with no Postgres and no usable container runtime. * * To run it locally, boot a Postgres and export the host, e.g.: - * docker run --rm -e POSTGRES_PASSWORD=password -p 5432:5432 postgres:18-alpine + * docker run --rm -e POSTGRES_PASSWORD=password -p 5432:5432 * DB_POSTGRESDB_HOST=localhost DB_POSTGRESDB_PASSWORD=password \ * pnpm test db-connection-monitor.recovery.postgres */ -const POSTGRES_IMAGE = 'postgres:18-alpine'; +const POSTGRES_IMAGE = postgresVersions.primary; const CONTAINER_TIMEOUT_MS = 180_000; const TEST_TIMEOUT_MS = 60_000; diff --git a/packages/@n8n/db/src/connection/__tests__/postgres-version-policy.test.ts b/packages/@n8n/db/src/connection/__tests__/postgres-version-policy.test.ts index 73383946572..be73cb2a1f5 100644 --- a/packages/@n8n/db/src/connection/__tests__/postgres-version-policy.test.ts +++ b/packages/@n8n/db/src/connection/__tests__/postgres-version-policy.test.ts @@ -1,3 +1,5 @@ +import postgresVersions from 'n8n-containers/postgres-versions.json'; + import { getPostgresVersionWarning, OLDEST_COMPATIBILITY_POSTGRES_MAJOR, @@ -36,3 +38,40 @@ describe('getPostgresVersionWarning', () => { expect(getPostgresVersionWarning('')).toBeNull(); }); }); + +// These thresholds and the DB test matrix move together every November. +describe('policy thresholds vs the versions CI tests', () => { + const tested = postgresVersions.matrix; + + it('should stay silent for every fully supported major CI tests', () => { + const supported = tested.filter(({ support }) => support === 'supported'); + + for (const { major } of supported) { + expect(getPostgresVersionWarning(`${major}.0`), `Postgres ${major}`).toBeNull(); + } + }); + + it('should warn about compatibility support only for the compatibility major CI tests', () => { + const compatibility = tested.filter(({ support }) => support === 'compatibility'); + + for (const { major } of compatibility) { + expect(getPostgresVersionWarning(`${major}.0`), `Postgres ${major}`).toContain( + 'compatibility support only', + ); + } + }); + + it('should treat the oldest tested major as the compatibility major', () => { + const oldest = Math.min(...tested.map(({ major }) => major)); + + expect(oldest).toBe(OLDEST_COMPATIBILITY_POSTGRES_MAJOR); + }); + + it('should treat the oldest fully supported tested major as the supported floor', () => { + const supported = tested + .filter(({ support }) => support === 'supported') + .map(({ major }) => major); + + expect(Math.min(...supported)).toBe(OLDEST_SUPPORTED_POSTGRES_MAJOR); + }); +}); diff --git a/packages/@n8n/engine/package.json b/packages/@n8n/engine/package.json index 2f27456f097..4e98fb2b86d 100644 --- a/packages/@n8n/engine/package.json +++ b/packages/@n8n/engine/package.json @@ -40,6 +40,7 @@ "@types/express": "catalog:", "@types/pg": "^8.15.6", "@types/supertest": "^6.0.3", + "n8n-containers": "workspace:*", "supertest": "^7.1.1", "testcontainers": "catalog:", "typescript": "catalog:typescript", diff --git a/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts b/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts index 9e2e7e251b1..d22d5f8aed8 100644 --- a/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts +++ b/packages/@n8n/engine/src/database/__tests__/workflow-execution.integration.test.ts @@ -1,5 +1,6 @@ import type { DataSource } from '@n8n/typeorm'; import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import postgresVersions from 'n8n-containers/postgres-versions.json'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createDataSource } from '../data-source'; @@ -10,7 +11,7 @@ describe('workflow_execution table (integration)', () => { let dataSource: DataSource; beforeAll(async () => { - container = await new PostgreSqlContainer('postgres:18-alpine').start(); + container = await new PostgreSqlContainer(postgresVersions.primary).start(); dataSource = createDataSource(container.getConnectionUri()); await dataSource.initialize(); await dataSource.runMigrations(); diff --git a/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts b/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts index 35b6a03a441..a9734d966c1 100644 --- a/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts +++ b/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts @@ -1,5 +1,6 @@ import type { DataSource } from '@n8n/typeorm'; import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import postgresVersions from 'n8n-containers/postgres-versions.json'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { StepStatus } from '../../execution/execution.types'; @@ -14,7 +15,7 @@ describe('workflow_step_execution table (integration)', () => { let dataSource: DataSource; beforeAll(async () => { - container = await new PostgreSqlContainer('postgres:18-alpine').start(); + container = await new PostgreSqlContainer(postgresVersions.primary).start(); dataSource = createDataSource(container.getConnectionUri()); await dataSource.initialize(); await dataSource.runMigrations(); diff --git a/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts b/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts index d63f1029487..3b27b095e59 100644 --- a/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/execution-start.integration.test.ts @@ -1,5 +1,6 @@ import type { DataSource } from '@n8n/typeorm'; import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import postgresVersions from 'n8n-containers/postgres-versions.json'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { AllowAllAdmittance } from '../../admittance'; @@ -35,7 +36,7 @@ describe('execution start (integration)', () => { let dataSource: DataSource; beforeAll(async () => { - container = await new PostgreSqlContainer('postgres:18-alpine').start(); + container = await new PostgreSqlContainer(postgresVersions.primary).start(); dataSource = createDataSource(container.getConnectionUri()); await dataSource.initialize(); await dataSource.runMigrations(); diff --git a/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts b/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts index 4ba471e4e80..c0d060b8fdc 100644 --- a/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts @@ -1,5 +1,6 @@ import type { DataSource } from '@n8n/typeorm'; import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import postgresVersions from 'n8n-containers/postgres-versions.json'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { AllowAllAdmittance } from '../../admittance'; @@ -34,7 +35,7 @@ describe('step execution (integration)', () => { let dataSource: DataSource; beforeAll(async () => { - container = await new PostgreSqlContainer('postgres:18-alpine').start(); + container = await new PostgreSqlContainer(postgresVersions.primary).start(); dataSource = createDataSource(container.getConnectionUri()); await dataSource.initialize(); await dataSource.runMigrations(); diff --git a/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts b/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts index 719c37b94c8..2f216d7761a 100644 --- a/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts +++ b/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts @@ -1,5 +1,6 @@ import type { DataSource } from '@n8n/typeorm'; import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import postgresVersions from 'n8n-containers/postgres-versions.json'; import request from 'supertest'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -22,7 +23,7 @@ describe('POST /api/workflow-executions (integration)', () => { let stop: () => Promise; beforeAll(async () => { - container = await new PostgreSqlContainer('postgres:18-alpine').start(); + container = await new PostgreSqlContainer(postgresVersions.primary).start(); dataSource = createDataSource(container.getConnectionUri()); await dataSource.initialize(); await dataSource.runMigrations(); diff --git a/packages/cli/test/integration/database/repositories/credentials.repository.test.ts b/packages/cli/test/integration/database/repositories/credentials.repository.test.ts index 2a794048326..686ab249593 100644 --- a/packages/cli/test/integration/database/repositories/credentials.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/credentials.repository.test.ts @@ -544,16 +544,20 @@ describe('CredentialsRepository', () => { const projectRoles = await roleService.rolesWithScope('project', scopes); const credentialRoles = await roleService.rolesWithScope('credential', scopes); + // Both approaches need an explicit order: without one, Postgres is free to + // return the rows in any order, and the two queries use different plans. const oldOptions = { filter: { projectId: teamProject.id, name: 'Test' }, take: 2, skip: 0, + order: { id: 'ASC' as const }, }; const newOptions = { filter: { name: 'Test' }, take: 2, skip: 0, + order: { id: 'ASC' as const }, }; // ACT - Old Approach @@ -574,7 +578,7 @@ describe('CredentialsRepository', () => { expect(newResult.count).toBe(oldResult[1]); expect(newResult.credentials).toHaveLength(oldResult[0].length); - // Check same credentials in same order (sorting should be consistent) + // Check same credentials in same order const oldIds = oldResult[0].map((c) => c.id); const newIds = newResult.credentials.map((c) => c.id); expect(newIds).toEqual(oldIds); diff --git a/packages/testing/containers/HELM-TESTING.md b/packages/testing/containers/HELM-TESTING.md index dc95521bb8f..360e6f1bb91 100644 --- a/packages/testing/containers/HELM-TESTING.md +++ b/packages/testing/containers/HELM-TESTING.md @@ -91,7 +91,9 @@ pnpm stack:helm --image ghcr.io/n8n-io/n8n:ci-12345 ### CLI Options ``` ---mode standalone (SQLite, default) or queue (PostgreSQL + Redis + workers) +--mode standalone (SQLite, default) or queue (PostgreSQL + Redis + workers). + Queue mode's Postgres chart and image are pinned in + postgres-versions.json. --image n8n Docker image (default: n8nio/n8n:local) --chart-ref Git branch/tag for n8n-hosting (default: main) --chart-repo Git repo URL (default: https://github.com/n8n-io/n8n-hosting.git) diff --git a/packages/testing/containers/README.md b/packages/testing/containers/README.md index 11291788910..c86382500f7 100644 --- a/packages/testing/containers/README.md +++ b/packages/testing/containers/README.md @@ -349,7 +349,7 @@ Now usable as `test.use({ capability: 'my-capability' })`. | Service | Helper | Description | |---------|--------|-------------| -| `postgres` | - | PostgreSQL database | +| `postgres` | - | PostgreSQL database (version from `postgres-versions.json`) | | `redis` | - | Redis for queue mode | | `mailpit` | ✓ | Email testing (SMTP + UI) | | `gitea` | ✓ | Git server for source control | diff --git a/packages/testing/containers/helm-stack.ts b/packages/testing/containers/helm-stack.ts index d4b9121f62b..9ad639c9bbc 100644 --- a/packages/testing/containers/helm-stack.ts +++ b/packages/testing/containers/helm-stack.ts @@ -12,6 +12,7 @@ import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { setTimeout as wait } from 'node:timers/promises'; +import postgresVersions from './postgres-versions.json'; import { TEST_CONTAINER_IMAGES } from './test-containers'; const DEFAULT_K3S_IMAGE = 'rancher/k3s:v1.32.2-k3s1'; @@ -236,9 +237,13 @@ function deployQueueInfrastructure(env: NodeJS.ProcessEnv): void { 'Add Bitnami repo', ); - log('Deploying PostgreSQL...'); + // By digest because the Bitnami catalog only publishes `postgresql:latest`, + // which the chart defaults to. + const { chartVersion, imageDigest } = postgresVersions.helm; + + log(`Deploying PostgreSQL (chart ${chartVersion})...`); execOnHost( - 'helm install postgresql bitnami/postgresql --set auth.username=n8n --set auth.password=n8n-test-password --set auth.database=n8n --set primary.resources.requests.cpu=100m --set primary.resources.requests.memory=256Mi --set primary.resources.limits.cpu=500m --set primary.resources.limits.memory=512Mi --wait --timeout 3m', + `helm install postgresql bitnami/postgresql --version ${chartVersion} --set image.digest=${imageDigest} --set auth.username=n8n --set auth.password=n8n-test-password --set auth.database=n8n --set primary.resources.requests.cpu=100m --set primary.resources.requests.memory=256Mi --set primary.resources.limits.cpu=500m --set primary.resources.limits.memory=512Mi --wait --timeout 3m`, env, 'Deploy PostgreSQL', ); diff --git a/packages/testing/containers/postgres-versions.json b/packages/testing/containers/postgres-versions.json new file mode 100644 index 00000000000..0a296198fef --- /dev/null +++ b/packages/testing/containers/postgres-versions.json @@ -0,0 +1,12 @@ +{ + "primary": "postgres:18.4-alpine", + "matrix": [ + { "major": 16, "support": "compatibility", "image": "postgres:16.14-alpine" }, + { "major": 17, "support": "supported", "image": "postgres:17.10-alpine" }, + { "major": 18, "support": "supported", "image": "postgres:18.4-alpine" } + ], + "helm": { + "chartVersion": "18.8.7", + "imageDigest": "sha256:09a0a8dc1c52c5a0c4ccc7febe3d4d4df1e5d50816e2312ef0e21d3c09084ec0" + } +} diff --git a/packages/testing/containers/test-containers.ts b/packages/testing/containers/test-containers.ts index 4a0aa6e5ddb..2915126e339 100644 --- a/packages/testing/containers/test-containers.ts +++ b/packages/testing/containers/test-containers.ts @@ -20,9 +20,11 @@ * N8N_DOCKER_IMAGE is also supported for backwards compatibility. */ +import postgresVersions from './postgres-versions.json'; + /** Default images - override via TEST_IMAGE_ env vars */ const DEFAULT_IMAGES = { - postgres: 'postgres:18-alpine', + postgres: postgresVersions.primary, redis: 'redis:alpine', caddy: 'caddy:alpine', n8n: 'n8nio/n8n:local', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c95fcdef1b6..3cf956b14d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2161,6 +2161,9 @@ importers: express: specifier: 'catalog:' version: 5.1.0 + n8n-containers: + specifier: workspace:* + version: link:../../testing/containers testcontainers: specifier: 'catalog:' version: 11.13.0 @@ -2290,6 +2293,9 @@ importers: '@types/supertest': specifier: ^6.0.3 version: 6.0.3 + n8n-containers: + specifier: workspace:* + version: link:../../testing/containers supertest: specifier: ^7.1.1 version: 7.1.1