From af4a9f85d9a9efd1949031ecfc83f8843b04fd03 Mon Sep 17 00:00:00 2001 From: Michael Dodsworth Date: Mon, 8 Sep 2025 17:59:28 -0700 Subject: [PATCH 1/2] feat: adding InfluxDB support --- package-lock.json | 23 ++ packages/modules/influxdb/Dockerfile | 1 + packages/modules/influxdb/package.json | 40 +++ packages/modules/influxdb/src/index.ts | 1 + .../influxdb/src/influxdb-container.test.ts | 210 +++++++++++++++ .../influxdb/src/influxdb-container.ts | 242 ++++++++++++++++++ packages/modules/influxdb/tsconfig.build.json | 12 + packages/modules/influxdb/tsconfig.json | 20 ++ 8 files changed, 549 insertions(+) create mode 100644 packages/modules/influxdb/Dockerfile create mode 100644 packages/modules/influxdb/package.json create mode 100644 packages/modules/influxdb/src/index.ts create mode 100644 packages/modules/influxdb/src/influxdb-container.test.ts create mode 100644 packages/modules/influxdb/src/influxdb-container.ts create mode 100644 packages/modules/influxdb/tsconfig.build.json create mode 100644 packages/modules/influxdb/tsconfig.json diff --git a/package-lock.json b/package-lock.json index bae56fbca..a79402a72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4642,6 +4642,13 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@influxdata/influxdb-client": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.35.0.tgz", + "integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==", + "dev": true, + "license": "MIT" + }, "node_modules/@inquirer/confirm": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.9.tgz", @@ -7292,6 +7299,10 @@ "resolved": "packages/modules/hivemq", "link": true }, + "node_modules/@testcontainers/influxdb": { + "resolved": "packages/modules/influxdb", + "link": true + }, "node_modules/@testcontainers/k3s": { "resolved": "packages/modules/k3s", "link": true @@ -22104,6 +22115,18 @@ "mqtt": "^5.14.0" } }, + "packages/modules/influxdb": { + "name": "@testcontainers/influxdb", + "version": "11.5.1", + "license": "MIT", + "dependencies": { + "testcontainers": "^11.5.1" + }, + "devDependencies": { + "@influxdata/influxdb-client": "^1.35.0", + "axios": "^1.7.9" + } + }, "packages/modules/k3s": { "name": "@testcontainers/k3s", "version": "11.5.1", diff --git a/packages/modules/influxdb/Dockerfile b/packages/modules/influxdb/Dockerfile new file mode 100644 index 000000000..b37e7aa04 --- /dev/null +++ b/packages/modules/influxdb/Dockerfile @@ -0,0 +1 @@ +FROM influxdb:2.7 \ No newline at end of file diff --git a/packages/modules/influxdb/package.json b/packages/modules/influxdb/package.json new file mode 100644 index 000000000..91ea575cd --- /dev/null +++ b/packages/modules/influxdb/package.json @@ -0,0 +1,40 @@ +{ + "name": "@testcontainers/influxdb", + "version": "11.5.1", + "license": "MIT", + "keywords": [ + "influxdb", + "influx", + "timeseries", + "testing", + "docker", + "testcontainers" + ], + "description": "InfluxDB module for Testcontainers", + "homepage": "https://github.com/testcontainers/testcontainers-node#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/testcontainers/testcontainers-node.git" + }, + "bugs": { + "url": "https://github.com/testcontainers/testcontainers-node/issues" + }, + "main": "build/index.js", + "files": [ + "build" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepack": "shx cp ../../../README.md . && shx cp ../../../LICENSE .", + "build": "tsc --project tsconfig.build.json" + }, + "devDependencies": { + "@influxdata/influxdb-client": "^1.35.0", + "axios": "^1.7.9" + }, + "dependencies": { + "testcontainers": "^11.5.1" + } +} \ No newline at end of file diff --git a/packages/modules/influxdb/src/index.ts b/packages/modules/influxdb/src/index.ts new file mode 100644 index 000000000..ddb49af22 --- /dev/null +++ b/packages/modules/influxdb/src/index.ts @@ -0,0 +1 @@ +export { InfluxDBContainer, StartedInfluxDBContainer } from "./influxdb-container"; \ No newline at end of file diff --git a/packages/modules/influxdb/src/influxdb-container.test.ts b/packages/modules/influxdb/src/influxdb-container.test.ts new file mode 100644 index 000000000..c7484ea66 --- /dev/null +++ b/packages/modules/influxdb/src/influxdb-container.test.ts @@ -0,0 +1,210 @@ +import { InfluxDBContainer, StartedInfluxDBContainer } from "./influxdb-container"; +import { getImage } from "../../../testcontainers/src/utils/test-helper"; +import axios from "axios"; + +const IMAGE = getImage(__dirname); + +describe("InfluxDBContainer", { timeout: 240_000 }, () => { + + describe("InfluxDB 2.x", { timeout: 240_000 }, () => { + let container: StartedInfluxDBContainer; + + afterEach(async () => { + if (container) { + await container.stop(); + } + }); + + it("should start with default configuration", async () => { + container = await new InfluxDBContainer(IMAGE).start(); + + expect(container.getPort()).toBeGreaterThan(0); + expect(container.getUsername()).toBe("test-user"); + expect(container.getPassword()).toBe("test-password"); + expect(container.getOrganization()).toBe("test-org"); + expect(container.getBucket()).toBe("test-bucket"); + expect(container.isInfluxDB2()).toBe(true); + }); + + it("should start with custom configuration", async () => { + container = await new InfluxDBContainer(IMAGE) + .withUsername("custom-user") + .withPassword("custom-password") + .withOrganization("custom-org") + .withBucket("custom-bucket") + .withRetention("7d") + .withAdminToken("my-super-secret-token") + .start(); + + expect(container.getUsername()).toBe("custom-user"); + expect(container.getPassword()).toBe("custom-password"); + expect(container.getOrganization()).toBe("custom-org"); + expect(container.getBucket()).toBe("custom-bucket"); + expect(container.getAdminToken()).toBe("my-super-secret-token"); + }); + + it("should respond to ping endpoint", async () => { + container = await new InfluxDBContainer(IMAGE).start(); + + const response = await axios.get(`${container.getUrl()}/ping`); + expect(response.status).toBe(204); + }); + + it("should provide correct connection string", async () => { + container = await new InfluxDBContainer(IMAGE) + .withOrganization("my-org") + .withBucket("my-bucket") + .withAdminToken("my-token") + .start(); + + const connectionString = container.getConnectionString(); + expect(connectionString).toContain(container.getUrl()); + expect(connectionString).toContain("org=my-org"); + expect(connectionString).toContain("bucket=my-bucket"); + expect(connectionString).toContain("token=my-token"); + }); + + it("should be able to write and query data", async () => { + const adminToken = "test-admin-token"; + container = await new InfluxDBContainer(IMAGE) + .withAdminToken(adminToken) + .start(); + + // Write data using the InfluxDB 2.x API + const writeUrl = `${container.getUrl()}/api/v2/write?org=${container.getOrganization()}&bucket=${container.getBucket()}`; + const writeData = "temperature,location=room1 value=23.5"; + + const writeResponse = await axios.post(writeUrl, writeData, { + headers: { + Authorization: `Token ${adminToken}`, + "Content-Type": "text/plain", + }, + }); + + expect(writeResponse.status).toBe(204); + + // Query data + const queryUrl = `${container.getUrl()}/api/v2/query?org=${container.getOrganization()}`; + const query = { + query: `from(bucket: "${container.getBucket()}") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "temperature")`, + type: "flux", + }; + + const queryResponse = await axios.post(queryUrl, query, { + headers: { + Authorization: `Token ${adminToken}`, + "Content-Type": "application/json", + }, + }); + + expect(queryResponse.status).toBe(200); + }); + + it("should start with specific version", async () => { + container = await new InfluxDBContainer(IMAGE).start(); + + expect(container.isInfluxDB2()).toBe(true); + + const response = await axios.get(`${container.getUrl()}/ping`); + expect(response.status).toBe(204); + }); + }); + + describe("InfluxDB 1.x", { timeout: 240_000 }, () => { + let container: StartedInfluxDBContainer; + + afterEach(async () => { + if (container) { + await container.stop(); + } + }); + + it("should start InfluxDB 1.8 with default configuration", async () => { + container = await new InfluxDBContainer("influxdb:1.8").start(); + + expect(container.getPort()).toBeGreaterThan(0); + expect(container.getUsername()).toBe("test-user"); + expect(container.getPassword()).toBe("test-password"); + expect(container.isInfluxDB2()).toBe(false); + }); + + it("should start InfluxDB 1.8 with custom database", async () => { + container = await new InfluxDBContainer("influxdb:1.8") + .withDatabase("mydb") + .withUsername("dbuser") + .withPassword("dbpass") + .withAuthEnabled(true) + .withAdmin("superadmin") + .withAdminPassword("superpass") + .start(); + + expect(container.getDatabase()).toBe("mydb"); + expect(container.getUsername()).toBe("dbuser"); + expect(container.getPassword()).toBe("dbpass"); + }); + + it("should respond to ping endpoint for v1", async () => { + container = await new InfluxDBContainer("influxdb:1.8") + .withAuthEnabled(false) + .start(); + + const response = await axios.get(`${container.getUrl()}/ping`); + expect(response.status).toBe(204); + }); + + it("should provide correct connection string for v1", async () => { + container = await new InfluxDBContainer("influxdb:1.8") + .withDatabase("testdb") + .withUsername("user1") + .withPassword("pass1") + .start(); + + const connectionString = container.getConnectionString(); + expect(connectionString).toContain(container.getHost()); + expect(connectionString).toContain("user1"); + expect(connectionString).toContain("pass1"); + expect(connectionString).toContain("/testdb"); + }); + + it("should be able to write data with v1 API", async () => { + container = await new InfluxDBContainer("influxdb:1.8") + .withDatabase("testdb") + .withAuthEnabled(false) + .start(); + + // Write data using the InfluxDB 1.x API + const writeUrl = `${container.getUrl()}/write?db=testdb`; + const writeData = "cpu_usage,host=server01 value=0.64"; + + const writeResponse = await axios.post(writeUrl, writeData, { + headers: { + "Content-Type": "text/plain", + }, + }); + + expect(writeResponse.status).toBe(204); + + // Query data + const queryUrl = `${container.getUrl()}/query?db=testdb&q=SELECT * FROM cpu_usage`; + const queryResponse = await axios.get(queryUrl); + + expect(queryResponse.status).toBe(200); + expect(queryResponse.data).toHaveProperty("results"); + }); + }); + + describe("Version Detection", { timeout: 240_000 }, () => { + it("should correctly detect InfluxDB 2.x from image tag", async () => { + // The default image in Dockerfile is 2.7 + const container = await new InfluxDBContainer(IMAGE).start(); + expect(container.isInfluxDB2()).toBe(true); + await container.stop(); + }); + + it("should correctly detect InfluxDB 1.x when using 1.8 image", async () => { + const container = await new InfluxDBContainer("influxdb:1.8").start(); + expect(container.isInfluxDB2()).toBe(false); + await container.stop(); + }); + }); +}); \ No newline at end of file diff --git a/packages/modules/influxdb/src/influxdb-container.ts b/packages/modules/influxdb/src/influxdb-container.ts new file mode 100644 index 000000000..a72f3cc13 --- /dev/null +++ b/packages/modules/influxdb/src/influxdb-container.ts @@ -0,0 +1,242 @@ +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; + +const INFLUXDB_PORT = 8086; + +export class InfluxDBContainer extends GenericContainer { + private username = "test-user"; + private password = "test-password"; + private organization = "test-org"; + private bucket = "test-bucket"; + private retention?: string; + private adminToken?: string; + private readonly httpWait = Wait.forHttp("/ping", INFLUXDB_PORT).forStatusCode(204); + + // InfluxDB 1.x specific properties + private authEnabled = true; + private admin = "admin"; + private adminPassword = "password"; + private database?: string; + + private isVersion2: boolean = true; + + constructor(image: string) { + super(image); + // Determine version from image tag (default to v2 if unknown) + this.isVersion2 = this.isInfluxDB2(this.imageName.tag ?? "latest"); + + this.withExposedPorts(INFLUXDB_PORT) + .withWaitStrategy(this.httpWait.withStartupTimeout(120_000)) + .withStartupTimeout(120_000); + + // Add basic credentials to the wait strategy + this.httpWait.withBasicCredentials(this.username, this.password); + } + + public withUsername(username: string): this { + this.username = username; + // keep wait strategy credentials up to date (mainly relevant for 1.x) + this.httpWait.withBasicCredentials(this.username, this.password); + return this; + } + + public withPassword(password: string): this { + this.password = password; + // keep wait strategy credentials up to date (mainly relevant for 1.x) + this.httpWait.withBasicCredentials(this.username, this.password); + return this; + } + + public withOrganization(organization: string): this { + this.organization = organization; + return this; + } + + public withBucket(bucket: string): this { + this.bucket = bucket; + return this; + } + + public withRetention(retention: string): this { + this.retention = retention; + return this; + } + + public withAdminToken(adminToken: string): this { + this.adminToken = adminToken; + return this; + } + + // InfluxDB 1.x specific methods + public withAuthEnabled(authEnabled: boolean): this { + this.authEnabled = authEnabled; + return this; + } + + public withAdmin(admin: string): this { + this.admin = admin; + return this; + } + + public withAdminPassword(adminPassword: string): this { + this.adminPassword = adminPassword; + return this; + } + + public withDatabase(database: string): this { + this.database = database; + return this; + } + + public override async start(): Promise { + // Re-evaluate version based on the final image tag + this.isVersion2 = this.isInfluxDB2(this.imageName.tag ?? "latest"); + if (this.isVersion2) { + this.configureInfluxDB2(); + } else { + this.configureInfluxDB1(); + } + + return new StartedInfluxDBContainer( + await super.start(), + this.username, + this.password, + this.organization, + this.bucket, + this.database, + this.adminToken, + this.isVersion2 + ); + } + + private configureInfluxDB2(): void { + const env: Record = { + DOCKER_INFLUXDB_INIT_MODE: "setup", + DOCKER_INFLUXDB_INIT_USERNAME: this.username, + DOCKER_INFLUXDB_INIT_PASSWORD: this.password, + DOCKER_INFLUXDB_INIT_ORG: this.organization, + DOCKER_INFLUXDB_INIT_BUCKET: this.bucket, + }; + + if (this.retention) { + env.DOCKER_INFLUXDB_INIT_RETENTION = this.retention; + } + + if (this.adminToken) { + env.DOCKER_INFLUXDB_INIT_ADMIN_TOKEN = this.adminToken; + } + + this.withEnvironment(env); + } + + private configureInfluxDB1(): void { + const env: Record = { + INFLUXDB_USER: this.username, + INFLUXDB_USER_PASSWORD: this.password, + INFLUXDB_HTTP_AUTH_ENABLED: String(this.authEnabled), + INFLUXDB_ADMIN_USER: this.admin, + INFLUXDB_ADMIN_PASSWORD: this.adminPassword, + }; + + if (this.database) { + env.INFLUXDB_DB = this.database; + } + + this.withEnvironment(env); + } + + private isInfluxDB2(tag: string): boolean { + if (tag === "latest") { + return true; // Assume latest is v2 + } + + // Parse version number + const versionMatch = tag.match(/^(\d+)(?:\.(\d+))?/); + if (versionMatch) { + const majorVersion = parseInt(versionMatch[1], 10); + return majorVersion >= 2; + } + + return true; // Default to v2 if unable to parse + } +} + +export class StartedInfluxDBContainer extends AbstractStartedContainer { + constructor( + startedTestContainer: StartedTestContainer, + private readonly username: string, + private readonly password: string, + private readonly organization: string, + private readonly bucket: string, + private readonly database?: string, + private readonly adminToken?: string, + private readonly isVersion2: boolean = true + ) { + super(startedTestContainer); + } + + public getPort(): number { + return this.getMappedPort(INFLUXDB_PORT); + } + + public getUsername(): string { + return this.username; + } + + public getPassword(): string { + return this.password; + } + + public getOrganization(): string { + return this.organization; + } + + public getBucket(): string { + return this.bucket; + } + + public getDatabase(): string | undefined { + return this.database; + } + + public getAdminToken(): string | undefined { + return this.adminToken; + } + + public isInfluxDB2(): boolean { + return this.isVersion2; + } + + /** + * @returns The URL to connect to InfluxDB + */ + public getUrl(): string { + return `http://${this.getHost()}:${this.getPort()}`; + } + + /** + * @returns A connection string for InfluxDB + */ + public getConnectionString(): string { + if (this.isVersion2) { + const params = new URLSearchParams({ + org: this.organization, + bucket: this.bucket, + }); + + if (this.adminToken) { + params.append("token", this.adminToken); + } + + return `${this.getUrl()}?${params.toString()}`; + } else { + // InfluxDB 1.x connection string format + const url = new URL(this.getUrl()); + url.username = this.username; + url.password = this.password; + if (this.database) { + url.pathname = `/${this.database}`; + } + return url.toString(); + } + } +} diff --git a/packages/modules/influxdb/tsconfig.build.json b/packages/modules/influxdb/tsconfig.build.json new file mode 100644 index 000000000..ff7390b10 --- /dev/null +++ b/packages/modules/influxdb/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "build", + "src/**/*.test.ts" + ], + "references": [ + { + "path": "../../testcontainers" + } + ] +} \ No newline at end of file diff --git a/packages/modules/influxdb/tsconfig.json b/packages/modules/influxdb/tsconfig.json new file mode 100644 index 000000000..4d74c3e41 --- /dev/null +++ b/packages/modules/influxdb/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "paths": { + "testcontainers": [ + "../../testcontainers/src" + ] + } + }, + "exclude": [ + "build" + ], + "references": [ + { + "path": "../../testcontainers" + } + ] +} \ No newline at end of file From 592a94854abdf58ad111e9bdf20b4a110db6f21c Mon Sep 17 00:00:00 2001 From: Michael Dodsworth Date: Mon, 8 Sep 2025 20:40:09 -0700 Subject: [PATCH 2/2] fix: linting --- packages/modules/influxdb/src/index.ts | 2 +- .../influxdb/src/influxdb-container.test.ts | 30 +++++++------------ .../influxdb/src/influxdb-container.ts | 12 ++++---- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/packages/modules/influxdb/src/index.ts b/packages/modules/influxdb/src/index.ts index ddb49af22..ed81c2030 100644 --- a/packages/modules/influxdb/src/index.ts +++ b/packages/modules/influxdb/src/index.ts @@ -1 +1 @@ -export { InfluxDBContainer, StartedInfluxDBContainer } from "./influxdb-container"; \ No newline at end of file +export { InfluxDBContainer, StartedInfluxDBContainer } from "./influxdb-container"; diff --git a/packages/modules/influxdb/src/influxdb-container.test.ts b/packages/modules/influxdb/src/influxdb-container.test.ts index c7484ea66..7ee087fa7 100644 --- a/packages/modules/influxdb/src/influxdb-container.test.ts +++ b/packages/modules/influxdb/src/influxdb-container.test.ts @@ -1,11 +1,10 @@ -import { InfluxDBContainer, StartedInfluxDBContainer } from "./influxdb-container"; -import { getImage } from "../../../testcontainers/src/utils/test-helper"; import axios from "axios"; +import { getImage } from "../../../testcontainers/src/utils/test-helper"; +import { InfluxDBContainer, StartedInfluxDBContainer } from "./influxdb-container"; const IMAGE = getImage(__dirname); describe("InfluxDBContainer", { timeout: 240_000 }, () => { - describe("InfluxDB 2.x", { timeout: 240_000 }, () => { let container: StartedInfluxDBContainer; @@ -66,21 +65,19 @@ describe("InfluxDBContainer", { timeout: 240_000 }, () => { it("should be able to write and query data", async () => { const adminToken = "test-admin-token"; - container = await new InfluxDBContainer(IMAGE) - .withAdminToken(adminToken) - .start(); + container = await new InfluxDBContainer(IMAGE).withAdminToken(adminToken).start(); // Write data using the InfluxDB 2.x API const writeUrl = `${container.getUrl()}/api/v2/write?org=${container.getOrganization()}&bucket=${container.getBucket()}`; const writeData = "temperature,location=room1 value=23.5"; - + const writeResponse = await axios.post(writeUrl, writeData, { headers: { Authorization: `Token ${adminToken}`, "Content-Type": "text/plain", }, }); - + expect(writeResponse.status).toBe(204); // Query data @@ -104,7 +101,7 @@ describe("InfluxDBContainer", { timeout: 240_000 }, () => { container = await new InfluxDBContainer(IMAGE).start(); expect(container.isInfluxDB2()).toBe(true); - + const response = await axios.get(`${container.getUrl()}/ping`); expect(response.status).toBe(204); }); @@ -144,9 +141,7 @@ describe("InfluxDBContainer", { timeout: 240_000 }, () => { }); it("should respond to ping endpoint for v1", async () => { - container = await new InfluxDBContainer("influxdb:1.8") - .withAuthEnabled(false) - .start(); + container = await new InfluxDBContainer("influxdb:1.8").withAuthEnabled(false).start(); const response = await axios.get(`${container.getUrl()}/ping`); expect(response.status).toBe(204); @@ -167,21 +162,18 @@ describe("InfluxDBContainer", { timeout: 240_000 }, () => { }); it("should be able to write data with v1 API", async () => { - container = await new InfluxDBContainer("influxdb:1.8") - .withDatabase("testdb") - .withAuthEnabled(false) - .start(); + container = await new InfluxDBContainer("influxdb:1.8").withDatabase("testdb").withAuthEnabled(false).start(); // Write data using the InfluxDB 1.x API const writeUrl = `${container.getUrl()}/write?db=testdb`; const writeData = "cpu_usage,host=server01 value=0.64"; - + const writeResponse = await axios.post(writeUrl, writeData, { headers: { "Content-Type": "text/plain", }, }); - + expect(writeResponse.status).toBe(204); // Query data @@ -207,4 +199,4 @@ describe("InfluxDBContainer", { timeout: 240_000 }, () => { await container.stop(); }); }); -}); \ No newline at end of file +}); diff --git a/packages/modules/influxdb/src/influxdb-container.ts b/packages/modules/influxdb/src/influxdb-container.ts index a72f3cc13..f21e66ee8 100644 --- a/packages/modules/influxdb/src/influxdb-container.ts +++ b/packages/modules/influxdb/src/influxdb-container.ts @@ -10,13 +10,13 @@ export class InfluxDBContainer extends GenericContainer { private retention?: string; private adminToken?: string; private readonly httpWait = Wait.forHttp("/ping", INFLUXDB_PORT).forStatusCode(204); - + // InfluxDB 1.x specific properties private authEnabled = true; private admin = "admin"; private adminPassword = "password"; private database?: string; - + private isVersion2: boolean = true; constructor(image: string) { @@ -148,14 +148,14 @@ export class InfluxDBContainer extends GenericContainer { if (tag === "latest") { return true; // Assume latest is v2 } - + // Parse version number const versionMatch = tag.match(/^(\d+)(?:\.(\d+))?/); if (versionMatch) { const majorVersion = parseInt(versionMatch[1], 10); return majorVersion >= 2; } - + return true; // Default to v2 if unable to parse } } @@ -222,11 +222,11 @@ export class StartedInfluxDBContainer extends AbstractStartedContainer { org: this.organization, bucket: this.bucket, }); - + if (this.adminToken) { params.append("token", this.adminToken); } - + return `${this.getUrl()}?${params.toString()}`; } else { // InfluxDB 1.x connection string format