From b904ea6c78c67f2fe085147f11273763dd4ff5ad Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Thu, 3 Apr 2025 09:30:10 +0100 Subject: [PATCH 1/5] Fix healthcheck for Postgis --- package-lock.json | 19 -- package.json | 3 +- .../postgresql/src/pgvector-container.test.ts | 45 ++++ .../postgresql/src/postgis-container.test.ts | 45 ++++ .../src/postgresql-container-snapshot.test.ts | 208 +++++++++++++++++ .../src/postgresql-container.test.ts | 209 +++--------------- .../postgresql/src/postgresql-container.ts | 7 +- .../src/timescale-container.test.ts | 45 ++++ .../abstract-started-container.ts | 5 + .../generic-container/generic-container.ts | 12 +- .../started-generic-container.ts | 6 +- packages/testcontainers/src/test-container.ts | 2 +- vitest.config.ts | 5 +- 13 files changed, 402 insertions(+), 209 deletions(-) create mode 100644 packages/modules/postgresql/src/pgvector-container.test.ts create mode 100644 packages/modules/postgresql/src/postgis-container.test.ts create mode 100644 packages/modules/postgresql/src/postgresql-container-snapshot.test.ts create mode 100644 packages/modules/postgresql/src/timescale-container.test.ts diff --git a/package-lock.json b/package-lock.json index cbeae833c..0a2fc4417 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "@eslint/js": "^9.22.0", "@eslint/json": "^0.11.0", "@vitest/coverage-v8": "^3.1.1", - "cross-env": "^7.0.3", "eslint": "^9.22.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.3", @@ -8022,24 +8021,6 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-fetch": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", diff --git a/package.json b/package.json index 41af06021..9e4d5293e 100755 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "scripts": { "pre-commit": "lint-staged", "docs:serve": "docker-compose up", - "test": "cross-env NODE_ENV=test DEBUG=testcontainers* vitest run", + "test": "vitest run", "test:ci": "npm run test -- --coverage", "format": "prettier --write package.json \"packages/**/*.ts\"", "lint": "eslint --fix package.json \"packages/**/*.ts\"", @@ -21,7 +21,6 @@ "@eslint/js": "^9.22.0", "@eslint/json": "^0.11.0", "@vitest/coverage-v8": "^3.1.1", - "cross-env": "^7.0.3", "eslint": "^9.22.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.3", diff --git a/packages/modules/postgresql/src/pgvector-container.test.ts b/packages/modules/postgresql/src/pgvector-container.test.ts new file mode 100644 index 000000000..c7c972961 --- /dev/null +++ b/packages/modules/postgresql/src/pgvector-container.test.ts @@ -0,0 +1,45 @@ +import { Client } from "pg"; +import { PostgreSqlContainer } from "./postgresql-container"; + +const IMAGE = "pgvector/pgvector:pg16"; + +describe("PgvectorContainer", { timeout: 180_000 }, () => { + it("should work", async () => { + const container = await new PostgreSqlContainer(IMAGE).start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); + + it("should restart", async () => { + const container = await new PostgreSqlContainer(IMAGE).start(); + await container.restart(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); +}); diff --git a/packages/modules/postgresql/src/postgis-container.test.ts b/packages/modules/postgresql/src/postgis-container.test.ts new file mode 100644 index 000000000..db86cbcac --- /dev/null +++ b/packages/modules/postgresql/src/postgis-container.test.ts @@ -0,0 +1,45 @@ +import { Client } from "pg"; +import { PostgreSqlContainer } from "./postgresql-container"; + +const IMAGE = "postgis/postgis:16-3.4"; + +describe("PostgisContainer", { timeout: 180_000 }, () => { + it("should work", async () => { + const container = await new PostgreSqlContainer(IMAGE).start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); + + it("should restart", async () => { + const container = await new PostgreSqlContainer(IMAGE).start(); + await container.restart(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); +}); diff --git a/packages/modules/postgresql/src/postgresql-container-snapshot.test.ts b/packages/modules/postgresql/src/postgresql-container-snapshot.test.ts new file mode 100644 index 000000000..9d1114c79 --- /dev/null +++ b/packages/modules/postgresql/src/postgresql-container-snapshot.test.ts @@ -0,0 +1,208 @@ +import { Client } from "pg"; +import { PostgreSqlContainer } from "./postgresql-container"; + +describe("PostgreSqlContainer snapshot and restore", { timeout: 180_000 }, () => { + // createAndRestoreFromSnapshot { + it("should create and restore from snapshot", async () => { + const container = await new PostgreSqlContainer().start(); + + // Connect to the database + let client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Create some test data + await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); + await client.query("INSERT INTO test_table (name) VALUES ('initial data')"); + + // Close connection before snapshot (otherwise we'll get an error because user is already connected) + await client.end(); + + // Take a snapshot + await container.snapshot(); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Modify the database + await client.query("INSERT INTO test_table (name) VALUES ('data after snapshot')"); + + // Verify both records exist + let result = await client.query("SELECT * FROM test_table ORDER BY id"); + expect(result.rows).toHaveLength(2); + expect(result.rows[0].name).toEqual("initial data"); + expect(result.rows[1].name).toEqual("data after snapshot"); + + // Close connection before restore (same reason as above) + await client.end(); + + // Restore to the snapshot + await container.restoreSnapshot(); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Verify only the initial data exists after restore + result = await client.query("SELECT * FROM test_table ORDER BY id"); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toEqual("initial data"); + + await client.end(); + await container.stop(); + }); + // } + + it("should use custom snapshot name", async () => { + const container = await new PostgreSqlContainer().start(); + const customSnapshotName = "my_custom_snapshot"; + + // Connect to the database + let client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Create a test table and insert data + await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); + await client.query("INSERT INTO test_table (name) VALUES ('initial data')"); + + // Close connection before snapshot + await client.end(); + + // Take a snapshot with custom name + await container.snapshot(customSnapshotName); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Modify the database + await client.query("INSERT INTO test_table (name) VALUES ('data after snapshot')"); + + // Close connection before restore + await client.end(); + + // Restore using the custom snapshot name + await container.restoreSnapshot(customSnapshotName); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Verify only the initial data exists after restore + const result = await client.query("SELECT * FROM test_table ORDER BY id"); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toEqual("initial data"); + + await client.end(); + await container.stop(); + }); + + it("should handle multiple snapshots", async () => { + const container = await new PostgreSqlContainer().start(); + + // Connect to the database + let client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Create a test table + await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); + + // Close connection before snapshot + await client.end(); + + // Take first snapshot with empty table + await container.snapshot("snapshot1"); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Add first record + await client.query("INSERT INTO test_table (name) VALUES ('data for snapshot 2')"); + + // Close connection before snapshot + await client.end(); + + // Take second snapshot with one record + await container.snapshot("snapshot2"); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Add second record + await client.query("INSERT INTO test_table (name) VALUES ('data after snapshots')"); + + // Verify we have two records + let result = await client.query("SELECT COUNT(*) as count FROM test_table"); + expect(result.rows[0].count).toEqual("2"); + + // Close connection before restore + await client.end(); + + // Restore to first snapshot (empty table) + await container.restoreSnapshot("snapshot1"); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Verify table is empty + result = await client.query("SELECT COUNT(*) as count FROM test_table"); + expect(result.rows[0].count).toEqual("0"); + + // Close connection before restore + await client.end(); + + // Restore to second snapshot (one record) + await container.restoreSnapshot("snapshot2"); + + // Reconnect to database + client = new Client({ + connectionString: container.getConnectionUri(), + }); + await client.connect(); + + // Verify we have one record + result = await client.query("SELECT * FROM test_table"); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toEqual("data for snapshot 2"); + + await client.end(); + await container.stop(); + }); + + it("should throw an error when trying to snapshot postgres system database", async () => { + const container = await new PostgreSqlContainer().withDatabase("postgres").start(); + + await expect(container.snapshot()).rejects.toThrow( + "Snapshot feature is not supported when using the postgres system database" + ); + + await expect(container.restoreSnapshot()).rejects.toThrow( + "Snapshot feature is not supported when using the postgres system database" + ); + + await container.stop(); + }); +}); diff --git a/packages/modules/postgresql/src/postgresql-container.test.ts b/packages/modules/postgresql/src/postgresql-container.test.ts index 52ff5ede9..5bfe5ade1 100644 --- a/packages/modules/postgresql/src/postgresql-container.test.ts +++ b/packages/modules/postgresql/src/postgresql-container.test.ts @@ -112,210 +112,61 @@ describe("PostgreSqlContainer", { timeout: 180_000 }, () => { await expect(() => container.start()).rejects.toThrow(); }); -}); - -describe("PostgreSqlContainer snapshot and restore", { timeout: 180_000 }, () => { - // createAndRestoreFromSnapshot { - it("should create and restore from snapshot", async () => { - const container = await new PostgreSqlContainer().start(); - - // Connect to the database - let client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Create some test data - await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); - await client.query("INSERT INTO test_table (name) VALUES ('initial data')"); - - // Close connection before snapshot (otherwise we'll get an error because user is already connected) - await client.end(); - - // Take a snapshot - await container.snapshot(); - - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - // Modify the database - await client.query("INSERT INTO test_table (name) VALUES ('data after snapshot')"); + it("should work with postgis", async () => { + const container = await new PostgreSqlContainer("postgis/postgis:16-3.4").start(); - // Verify both records exist - let result = await client.query("SELECT * FROM test_table ORDER BY id"); - expect(result.rows).toHaveLength(2); - expect(result.rows[0].name).toEqual("initial data"); - expect(result.rows[1].name).toEqual("data after snapshot"); - - // Close connection before restore (same reason as above) - await client.end(); - - // Restore to the snapshot - await container.restoreSnapshot(); - - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), }); await client.connect(); - // Verify only the initial data exists after restore - result = await client.query("SELECT * FROM test_table ORDER BY id"); - expect(result.rows).toHaveLength(1); - expect(result.rows[0].name).toEqual("initial data"); + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); await client.end(); await container.stop(); }); - // } - - it("should use custom snapshot name", async () => { - const container = await new PostgreSqlContainer().start(); - const customSnapshotName = "my_custom_snapshot"; - - // Connect to the database - let client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Create a test table and insert data - await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); - await client.query("INSERT INTO test_table (name) VALUES ('initial data')"); - - // Close connection before snapshot - await client.end(); - - // Take a snapshot with custom name - await container.snapshot(customSnapshotName); - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Modify the database - await client.query("INSERT INTO test_table (name) VALUES ('data after snapshot')"); - - // Close connection before restore - await client.end(); - - // Restore using the custom snapshot name - await container.restoreSnapshot(customSnapshotName); + it("should work with pgvector", async () => { + const container = await new PostgreSqlContainer("pgvector/pgvector:pg16").start(); - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), }); await client.connect(); - // Verify only the initial data exists after restore - const result = await client.query("SELECT * FROM test_table ORDER BY id"); - expect(result.rows).toHaveLength(1); - expect(result.rows[0].name).toEqual("initial data"); + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); await client.end(); await container.stop(); }); - it("should handle multiple snapshots", async () => { - const container = await new PostgreSqlContainer().start(); - - // Connect to the database - let client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Create a test table - await client.query("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)"); - - // Close connection before snapshot - await client.end(); - - // Take first snapshot with empty table - await container.snapshot("snapshot1"); - - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Add first record - await client.query("INSERT INTO test_table (name) VALUES ('data for snapshot 2')"); - - // Close connection before snapshot - await client.end(); - - // Take second snapshot with one record - await container.snapshot("snapshot2"); - - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Add second record - await client.query("INSERT INTO test_table (name) VALUES ('data after snapshots')"); - - // Verify we have two records - let result = await client.query("SELECT COUNT(*) as count FROM test_table"); - expect(result.rows[0].count).toEqual("2"); + it("should work with timescale", async () => { + const container = await new PostgreSqlContainer("timescale/timescaledb:2.1.0-pg11").start(); - // Close connection before restore - await client.end(); - - // Restore to first snapshot (empty table) - await container.restoreSnapshot("snapshot1"); - - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), - }); - await client.connect(); - - // Verify table is empty - result = await client.query("SELECT COUNT(*) as count FROM test_table"); - expect(result.rows[0].count).toEqual("0"); - - // Close connection before restore - await client.end(); - - // Restore to second snapshot (one record) - await container.restoreSnapshot("snapshot2"); - - // Reconnect to database - client = new Client({ - connectionString: container.getConnectionUri(), + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), }); await client.connect(); - // Verify we have one record - result = await client.query("SELECT * FROM test_table"); - expect(result.rows).toHaveLength(1); - expect(result.rows[0].name).toEqual("data for snapshot 2"); + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); await client.end(); await container.stop(); }); - - it("should throw an error when trying to snapshot postgres system database", async () => { - const container = await new PostgreSqlContainer().withDatabase("postgres").start(); - - await expect(container.snapshot()).rejects.toThrow( - "Snapshot feature is not supported when using the postgres system database" - ); - - await expect(container.restoreSnapshot()).rejects.toThrow( - "Snapshot feature is not supported when using the postgres system database" - ); - - await container.stop(); - }); }); diff --git a/packages/modules/postgresql/src/postgresql-container.ts b/packages/modules/postgresql/src/postgresql-container.ts index b841b672d..eeea3a350 100755 --- a/packages/modules/postgresql/src/postgresql-container.ts +++ b/packages/modules/postgresql/src/postgresql-container.ts @@ -10,7 +10,12 @@ export class PostgreSqlContainer extends GenericContainer { constructor(image = "postgres:13.3-alpine") { super(image); this.withExposedPorts(POSTGRES_PORT); - this.withWaitStrategy(Wait.forHealthCheck()); + this.withWaitStrategy( + Wait.forAll([Wait.forHealthCheck(), Wait.forLogMessage(/.*database system is ready to accept connections.*/, 2)]) + ); + this.withRestartWaitStrategy( + Wait.forAll([Wait.forHealthCheck(), Wait.forLogMessage(/.*database system is ready to accept connections.*/, 1)]) + ); this.withStartupTimeout(120_000); } diff --git a/packages/modules/postgresql/src/timescale-container.test.ts b/packages/modules/postgresql/src/timescale-container.test.ts new file mode 100644 index 000000000..c66a4ba15 --- /dev/null +++ b/packages/modules/postgresql/src/timescale-container.test.ts @@ -0,0 +1,45 @@ +import { Client } from "pg"; +import { PostgreSqlContainer } from "./postgresql-container"; + +const IMAGE = "timescale/timescaledb:2.1.0-pg11"; + +describe("TimescaleContainer", { timeout: 180_000 }, () => { + it("should work", async () => { + const container = await new PostgreSqlContainer(IMAGE).start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); + + it("should restart", async () => { + const container = await new PostgreSqlContainer(IMAGE).start(); + await container.restart(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await client.connect(); + + const result = await client.query("SELECT 1"); + expect(result.rows[0]).toEqual({ "?column?": 1 }); + + await client.end(); + await container.stop(); + }); +}); diff --git a/packages/testcontainers/src/generic-container/abstract-started-container.ts b/packages/testcontainers/src/generic-container/abstract-started-container.ts index 1ccac33b7..9fcab493c 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -1,6 +1,7 @@ import { Readable } from "stream"; import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; import { CommitOptions, ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types"; +import { WaitStrategy } from "../wait-strategies/wait-strategy"; export class AbstractStartedContainer implements StartedTestContainer { constructor(protected readonly startedTestContainer: StartedTestContainer) {} @@ -23,6 +24,10 @@ export class AbstractStartedContainer implements StartedTestContainer { protected containerStopped?(): Promise; + public setWaitStrategy(waitStrategy: WaitStrategy): void { + this.startedTestContainer.setWaitStrategy(waitStrategy); + } + public async restart(options?: Partial): Promise { return this.startedTestContainer.restart(options); } diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 5b50bc91c..09cb0b00b 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -48,6 +48,7 @@ export class GenericContainer implements TestContainer { protected imageName: ImageName; protected startupTimeout?: number; protected waitStrategy: WaitStrategy = Wait.forListeningPorts(); + protected restartWaitStrategy?: WaitStrategy; protected environment: Record = {}; protected exposedPorts: PortWithOptionalBinding[] = []; protected reuse = false; @@ -161,7 +162,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy, + this.restartWaitStrategy ?? this.waitStrategy, this.autoRemove ); } @@ -230,7 +231,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.waitStrategy, + this.restartWaitStrategy ?? this.waitStrategy, this.autoRemove ); @@ -402,7 +403,7 @@ export class GenericContainer implements TestContainer { Test: healthCheck.test, Interval: healthCheck.interval ? toNanos(healthCheck.interval) : 0, Timeout: healthCheck.timeout ? toNanos(healthCheck.timeout) : 0, - Retries: healthCheck.retries || 0, + Retries: healthCheck.retries ?? 0, StartPeriod: healthCheck.startPeriod ? toNanos(healthCheck.startPeriod) : 0, }; @@ -419,6 +420,11 @@ export class GenericContainer implements TestContainer { return this; } + public withRestartWaitStrategy(waitStrategy: WaitStrategy): this { + this.restartWaitStrategy = waitStrategy; + return this; + } + public withDefaultLogDriver(): this { this.hostConfig.LogConfig = { Type: "json-file", diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index e97d8aae6..718d42ac8 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -16,7 +16,7 @@ import { StoppedGenericContainer } from "./stopped-generic-container"; export class StartedGenericContainer implements StartedTestContainer { private stoppedContainer?: StoppedTestContainer; - private stopContainerLock = new AsyncLock(); + private readonly stopContainerLock = new AsyncLock(); constructor( private readonly container: Dockerode.Container, @@ -24,7 +24,7 @@ export class StartedGenericContainer implements StartedTestContainer { private inspectResult: ContainerInspectInfo, private boundPorts: BoundPorts, private readonly name: string, - private readonly waitStrategy: WaitStrategy, + private readonly restartWaitStrategy: WaitStrategy, private readonly autoRemove: boolean ) {} @@ -94,7 +94,7 @@ export class StartedGenericContainer implements StartedTestContainer { Array.from(this.boundPorts.iterator()).map((port) => port[0]) ); - await waitForContainer(client, this.container, this.waitStrategy, this.boundPorts, startTime); + await waitForContainer(client, this.container, this.restartWaitStrategy, this.boundPorts, startTime); log.info(`Restarted container`, { containerId: this.container.id }); } diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index f4154ca78..720b1551d 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -32,6 +32,7 @@ export interface TestContainer { withExposedPorts(...ports: PortWithOptionalBinding[]): this; withBindMounts(bindMounts: BindMount[]): this; withWaitStrategy(waitStrategy: WaitStrategy): this; + withRestartWaitStrategy(waitStrategy: WaitStrategy): this; withStartupTimeout(startupTimeoutMs: number): this; withNetwork(network: StartedNetwork): this; withNetworkMode(networkMode: string): this; @@ -47,7 +48,6 @@ export interface TestContainer { withCopyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): this; withCopyContentToContainer(contentsToCopy: ContentToCopy[]): this; withCopyArchivesToContainer(archivesToCopy: ArchiveToCopy[]): this; - withWorkingDir(workingDir: string): this; withResourcesQuota(resourcesQuota: ResourcesQuota): this; withSharedMemorySize(bytes: number): this; diff --git a/vitest.config.ts b/vitest.config.ts index fb3d8d26d..77ca20caa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,12 @@ -import path from "path"; +import * as path from "path"; import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, + env: { + DEBUG: "testcontainers*", + }, silent: "passed-only", mockReset: true, restoreMocks: true, From 8560f4c5602d9ef1c5a971d99bb463ad7e70bc61 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Thu, 3 Apr 2025 09:37:53 +0100 Subject: [PATCH 2/5] Update docs --- docs/features/wait-strategies.md | 11 +++++++++++ docs/modules/postgresql.md | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index 5f6bfa9d7..4ca6c71e7 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -256,3 +256,14 @@ const container = await new GenericContainer("alpine") .withWaitStrategy(new ReadyAfterDelayWaitStrategy()) .start(); ``` + +## Restart wait strategies + +The default wait strategy used when a container restarts is the same as the one used when it starts. However sometimes a container behaves differently between its first start and subsequent starts. Postgres is one such example. For this reason you can specify a wait strategy specifically used for restarts: + +```javascript +const container = await new GenericContainer("postgres") + .withWaitStrategy(Wait.forLogMessage(/.*database system is ready to accept connections.*/, 2)) + .withRestartWaitStrategy(Wait.forLogMessage(/.*database system is ready to accept connections.*/, 1)) + .start(); +``` diff --git a/docs/modules/postgresql.md b/docs/modules/postgresql.md index ebd8ae14a..4bcb903fc 100644 --- a/docs/modules/postgresql.md +++ b/docs/modules/postgresql.md @@ -38,5 +38,5 @@ tests very modular, since they always run on a brand-new database. not work if the database for the container is set to `"postgres"`. -[Test with a reusable Postgres container](../../packages/modules/postgresql/src/postgresql-container.test.ts) inside_block:createAndRestoreFromSnapshot +[Test with a reusable Postgres container](../../packages/modules/postgresql/src/postgresql-container-snapshot.test.ts) inside_block:createAndRestoreFromSnapshot \ No newline at end of file From 966ab5f4b24baa8bc184bbbd6724fdfde708358e Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Thu, 3 Apr 2025 09:40:04 +0100 Subject: [PATCH 3/5] Remove old method --- .../src/generic-container/abstract-started-container.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/testcontainers/src/generic-container/abstract-started-container.ts b/packages/testcontainers/src/generic-container/abstract-started-container.ts index 9fcab493c..1ccac33b7 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -1,7 +1,6 @@ import { Readable } from "stream"; import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; import { CommitOptions, ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types"; -import { WaitStrategy } from "../wait-strategies/wait-strategy"; export class AbstractStartedContainer implements StartedTestContainer { constructor(protected readonly startedTestContainer: StartedTestContainer) {} @@ -24,10 +23,6 @@ export class AbstractStartedContainer implements StartedTestContainer { protected containerStopped?(): Promise; - public setWaitStrategy(waitStrategy: WaitStrategy): void { - this.startedTestContainer.setWaitStrategy(waitStrategy); - } - public async restart(options?: Partial): Promise { return this.startedTestContainer.restart(options); } From 2b92f98b338c137980c2dbfde261a9cb93d134d2 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Thu, 3 Apr 2025 09:41:25 +0100 Subject: [PATCH 4/5] Remove duplicate tests --- .../src/postgresql-container.test.ts | 57 ------------------- 1 file changed, 57 deletions(-) diff --git a/packages/modules/postgresql/src/postgresql-container.test.ts b/packages/modules/postgresql/src/postgresql-container.test.ts index 5bfe5ade1..9dbffaf02 100644 --- a/packages/modules/postgresql/src/postgresql-container.test.ts +++ b/packages/modules/postgresql/src/postgresql-container.test.ts @@ -112,61 +112,4 @@ describe("PostgreSqlContainer", { timeout: 180_000 }, () => { await expect(() => container.start()).rejects.toThrow(); }); - - it("should work with postgis", async () => { - const container = await new PostgreSqlContainer("postgis/postgis:16-3.4").start(); - - const client = new Client({ - host: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - user: container.getUsername(), - password: container.getPassword(), - }); - await client.connect(); - - const result = await client.query("SELECT 1"); - expect(result.rows[0]).toEqual({ "?column?": 1 }); - - await client.end(); - await container.stop(); - }); - - it("should work with pgvector", async () => { - const container = await new PostgreSqlContainer("pgvector/pgvector:pg16").start(); - - const client = new Client({ - host: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - user: container.getUsername(), - password: container.getPassword(), - }); - await client.connect(); - - const result = await client.query("SELECT 1"); - expect(result.rows[0]).toEqual({ "?column?": 1 }); - - await client.end(); - await container.stop(); - }); - - it("should work with timescale", async () => { - const container = await new PostgreSqlContainer("timescale/timescaledb:2.1.0-pg11").start(); - - const client = new Client({ - host: container.getHost(), - port: container.getPort(), - database: container.getDatabase(), - user: container.getUsername(), - password: container.getPassword(), - }); - await client.connect(); - - const result = await client.query("SELECT 1"); - expect(result.rows[0]).toEqual({ "?column?": 1 }); - - await client.end(); - await container.stop(); - }); }); From 8ea29bb6d09439e1f1759dfa636e6dbefa03c626 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Thu, 3 Apr 2025 11:03:21 +0100 Subject: [PATCH 5/5] Use pg_isready as healthcheck --- docs/features/wait-strategies.md | 11 ----------- .../modules/postgresql/src/postgresql-container.ts | 12 +++++------- .../src/generic-container/generic-container.ts | 10 ++-------- .../generic-container/started-generic-container.ts | 4 ++-- packages/testcontainers/src/test-container.ts | 1 - 5 files changed, 9 insertions(+), 29 deletions(-) diff --git a/docs/features/wait-strategies.md b/docs/features/wait-strategies.md index 4ca6c71e7..5f6bfa9d7 100644 --- a/docs/features/wait-strategies.md +++ b/docs/features/wait-strategies.md @@ -256,14 +256,3 @@ const container = await new GenericContainer("alpine") .withWaitStrategy(new ReadyAfterDelayWaitStrategy()) .start(); ``` - -## Restart wait strategies - -The default wait strategy used when a container restarts is the same as the one used when it starts. However sometimes a container behaves differently between its first start and subsequent starts. Postgres is one such example. For this reason you can specify a wait strategy specifically used for restarts: - -```javascript -const container = await new GenericContainer("postgres") - .withWaitStrategy(Wait.forLogMessage(/.*database system is ready to accept connections.*/, 2)) - .withRestartWaitStrategy(Wait.forLogMessage(/.*database system is ready to accept connections.*/, 1)) - .start(); -``` diff --git a/packages/modules/postgresql/src/postgresql-container.ts b/packages/modules/postgresql/src/postgresql-container.ts index eeea3a350..9bd774008 100755 --- a/packages/modules/postgresql/src/postgresql-container.ts +++ b/packages/modules/postgresql/src/postgresql-container.ts @@ -10,12 +10,7 @@ export class PostgreSqlContainer extends GenericContainer { constructor(image = "postgres:13.3-alpine") { super(image); this.withExposedPorts(POSTGRES_PORT); - this.withWaitStrategy( - Wait.forAll([Wait.forHealthCheck(), Wait.forLogMessage(/.*database system is ready to accept connections.*/, 2)]) - ); - this.withRestartWaitStrategy( - Wait.forAll([Wait.forHealthCheck(), Wait.forLogMessage(/.*database system is ready to accept connections.*/, 1)]) - ); + this.withWaitStrategy(Wait.forHealthCheck()); this.withStartupTimeout(120_000); } @@ -42,7 +37,10 @@ export class PostgreSqlContainer extends GenericContainer { }); if (!this.healthCheck) { this.withHealthCheck({ - test: ["CMD-SHELL", `PGPASSWORD=${this.password} psql -U ${this.username} -d ${this.database} -c 'SELECT 1;'`], + test: [ + "CMD-SHELL", + `PGPASSWORD=${this.password} pg_isready --host localhost --username ${this.username} --dbname ${this.database}`, + ], interval: 250, timeout: 1000, retries: 1000, diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 09cb0b00b..6aad8e9b4 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -48,7 +48,6 @@ export class GenericContainer implements TestContainer { protected imageName: ImageName; protected startupTimeout?: number; protected waitStrategy: WaitStrategy = Wait.forListeningPorts(); - protected restartWaitStrategy?: WaitStrategy; protected environment: Record = {}; protected exposedPorts: PortWithOptionalBinding[] = []; protected reuse = false; @@ -162,7 +161,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.restartWaitStrategy ?? this.waitStrategy, + this.waitStrategy, this.autoRemove ); } @@ -231,7 +230,7 @@ export class GenericContainer implements TestContainer { inspectResult, boundPorts, inspectResult.Name, - this.restartWaitStrategy ?? this.waitStrategy, + this.waitStrategy, this.autoRemove ); @@ -420,11 +419,6 @@ export class GenericContainer implements TestContainer { return this; } - public withRestartWaitStrategy(waitStrategy: WaitStrategy): this { - this.restartWaitStrategy = waitStrategy; - return this; - } - public withDefaultLogDriver(): this { this.hostConfig.LogConfig = { Type: "json-file", diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 718d42ac8..7bb052d76 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -24,7 +24,7 @@ export class StartedGenericContainer implements StartedTestContainer { private inspectResult: ContainerInspectInfo, private boundPorts: BoundPorts, private readonly name: string, - private readonly restartWaitStrategy: WaitStrategy, + private readonly waitStrategy: WaitStrategy, private readonly autoRemove: boolean ) {} @@ -94,7 +94,7 @@ export class StartedGenericContainer implements StartedTestContainer { Array.from(this.boundPorts.iterator()).map((port) => port[0]) ); - await waitForContainer(client, this.container, this.restartWaitStrategy, this.boundPorts, startTime); + await waitForContainer(client, this.container, this.waitStrategy, this.boundPorts, startTime); log.info(`Restarted container`, { containerId: this.container.id }); } diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index 720b1551d..148ef18e6 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -32,7 +32,6 @@ export interface TestContainer { withExposedPorts(...ports: PortWithOptionalBinding[]): this; withBindMounts(bindMounts: BindMount[]): this; withWaitStrategy(waitStrategy: WaitStrategy): this; - withRestartWaitStrategy(waitStrategy: WaitStrategy): this; withStartupTimeout(startupTimeoutMs: number): this; withNetwork(network: StartedNetwork): this; withNetworkMode(networkMode: string): this;