diff --git a/docs/modules/influxdb.md b/docs/modules/influxdb.md new file mode 100644 index 000000000..d9ed35259 --- /dev/null +++ b/docs/modules/influxdb.md @@ -0,0 +1,29 @@ +# InfluxDB + +## Install + +```bash +npm install @testcontainers/influxdb --save-dev +``` + +## Examples + +The InfluxDB 2.x examples use the following libraries: + +- [@influxdata/influxdb-client](https://www.npmjs.com/package/@influxdata/influxdb-client) + + npm install @influxdata/influxdb-client + +`InfluxDBContainer` supports both InfluxDB 2.x and the legacy 1.x line. The major version is derived from the image tag, so substitute `IMAGE` with a `2.x` tag (the default flavour) or a `1.x` tag from the [container registry](https://hub.docker.com/_/influxdb). + +### Write and query points (InfluxDB 2.x) + + +[](../../packages/modules/influxdb/src/influxdb-container.test.ts) inside_block:influxdb2WriteAndQuery + + +### Write and query points (InfluxDB 1.x) + + +[](../../packages/modules/influxdb/src/influxdb-container.test.ts) inside_block:influxdb1WriteAndQuery + diff --git a/mkdocs.yml b/mkdocs.yml index 7ca59af31..dd643c9ea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -67,6 +67,7 @@ nav: - Etcd: modules/etcd.md - GCloud: modules/gcloud.md - HiveMQ: modules/hivemq.md + - InfluxDB: modules/influxdb.md - K3s: modules/k3s.md - Kafka: modules/kafka.md - KurrentDB: modules/kurrentdb.md diff --git a/package-lock.json b/package-lock.json index eed11b0c9..7007a0eee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3627,6 +3627,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/ansi": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", @@ -5078,6 +5085,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 @@ -15655,6 +15666,17 @@ "mqtt": "^5.15.2" } }, + "packages/modules/influxdb": { + "name": "@testcontainers/influxdb", + "version": "12.1.0", + "license": "MIT", + "dependencies": { + "testcontainers": "^12.1.0" + }, + "devDependencies": { + "@influxdata/influxdb-client": "^1.35.0" + } + }, "packages/modules/k3s": { "name": "@testcontainers/k3s", "version": "12.1.0", diff --git a/packages/modules/influxdb/Dockerfile b/packages/modules/influxdb/Dockerfile new file mode 100644 index 000000000..5f53ec75d --- /dev/null +++ b/packages/modules/influxdb/Dockerfile @@ -0,0 +1,2 @@ +FROM influxdb:2.7 +FROM influxdb:1.11 diff --git a/packages/modules/influxdb/package.json b/packages/modules/influxdb/package.json new file mode 100644 index 000000000..c1e7f6978 --- /dev/null +++ b/packages/modules/influxdb/package.json @@ -0,0 +1,39 @@ +{ + "name": "@testcontainers/influxdb", + "version": "12.1.0", + "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" + }, + "dependencies": { + "testcontainers": "^12.1.0" + } +} diff --git a/packages/modules/influxdb/src/index.ts b/packages/modules/influxdb/src/index.ts new file mode 100644 index 000000000..ed81c2030 --- /dev/null +++ b/packages/modules/influxdb/src/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 000000000..49fe0f656 --- /dev/null +++ b/packages/modules/influxdb/src/influxdb-container.test.ts @@ -0,0 +1,88 @@ +import { InfluxDB, Point } from "@influxdata/influxdb-client"; +import { expect } from "vitest"; +import { getImage } from "../../../testcontainers/src/utils/test-helper"; +import { InfluxDBContainer } from "./influxdb-container"; + +const INFLUXDB2_IMAGE = getImage(__dirname, 0); +const INFLUXDB1_IMAGE = getImage(__dirname, 1); + +describe("InfluxDBContainer", { timeout: 240_000 }, () => { + describe("InfluxDB 2.x", () => { + it("should start and expose the connection details", async () => { + await using container = await new InfluxDBContainer(INFLUXDB2_IMAGE).start(); + + expect(container.isInfluxDB2()).toBe(true); + expect(container.getPort()).toBeGreaterThan(0); + expect(container.getUrl()).toEqual(`http://${container.getHost()}:${container.getPort()}`); + expect(container.getOrganization()).toBe("test-org"); + expect(container.getBucket()).toBe("test-bucket"); + + const ping = await fetch(`${container.getUrl()}/ping`); + expect(ping.status).toBe(204); + }); + + it("should write and query points with the official client", async () => { + // influxdb2WriteAndQuery { + await using container = await new InfluxDBContainer(INFLUXDB2_IMAGE).withAdminToken("my-secret-token").start(); + + const influxDB = new InfluxDB({ url: container.getUrl(), token: container.getAdminToken() }); + + const writeApi = influxDB.getWriteApi(container.getOrganization(), container.getBucket()); + writeApi.writePoint(new Point("temperature").tag("location", "room1").floatField("value", 23.5)); + await writeApi.close(); + + const queryApi = influxDB.getQueryApi(container.getOrganization()); + const rows = await queryApi.collectRows<{ _value: number }>( + `from(bucket: "${container.getBucket()}") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "temperature")` + ); + + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]._value).toBe(23.5); + // } + }); + + it("should apply custom configuration", async () => { + await using container = await new InfluxDBContainer(INFLUXDB2_IMAGE) + .withUsername("custom-user") + .withPassword("custom-password") + .withOrganization("custom-org") + .withBucket("custom-bucket") + .withRetention("24h") + .withAdminToken("custom-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("custom-token"); + }); + }); + + describe("InfluxDB 1.x", () => { + it("should start a 1.x database and write/query over HTTP", async () => { + // influxdb1WriteAndQuery { + await using container = await new InfluxDBContainer(INFLUXDB1_IMAGE) + .withDatabase("testdb") + .withAuthEnabled(false) + .start(); + + expect(container.isInfluxDB2()).toBe(false); + expect(container.getDatabase()).toBe("testdb"); + + const writeResponse = await fetch(`${container.getUrl()}/write?db=${container.getDatabase()}`, { + method: "POST", + body: "cpu_load,host=server01 value=0.64", + }); + expect(writeResponse.status).toBe(204); + + const query = encodeURIComponent("SELECT * FROM cpu_load"); + const queryResponse = await fetch(`${container.getUrl()}/query?db=${container.getDatabase()}&q=${query}`); + expect(queryResponse.status).toBe(200); + + const body = (await queryResponse.json()) as { results: unknown[] }; + expect(body.results).toBeDefined(); + // } + }); + }); +}); diff --git a/packages/modules/influxdb/src/influxdb-container.ts b/packages/modules/influxdb/src/influxdb-container.ts new file mode 100644 index 000000000..698f71349 --- /dev/null +++ b/packages/modules/influxdb/src/influxdb-container.ts @@ -0,0 +1,197 @@ +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; + +const INFLUXDB_PORT = 8086; + +interface InfluxDBConnectionDetails { + version2: boolean; + username: string; + password: string; + organization: string; + bucket: string; + adminToken?: string; + database?: string; +} + +/** + * Testcontainers module for InfluxDB. + * + * Supports both InfluxDB 2.x (organization/bucket/token based) and the legacy + * InfluxDB 1.x (database/user based). The major version is derived from the + * image tag, so anything below `2` (or a non-numeric tag such as `latest`) is + * treated as 2.x. + */ +export class InfluxDBContainer extends GenericContainer { + private username = "test-user"; + private password = "test-password"; + + // InfluxDB 2.x + private organization = "test-org"; + private bucket = "test-bucket"; + private retention?: string; + private adminToken?: string; + + // InfluxDB 1.x + private database?: string; + private authEnabled = true; + private adminUsername = "admin"; + private adminPassword = "admin-password"; + + constructor(image: string) { + super(image); + this.withExposedPorts(INFLUXDB_PORT) + .withWaitStrategy(Wait.forHttp("/ping", INFLUXDB_PORT).forStatusCode(204)) + .withStartupTimeout(120_000); + } + + public withUsername(username: string): this { + this.username = username; + return this; + } + + public withPassword(password: string): this { + this.password = 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; + } + + public withDatabase(database: string): this { + this.database = database; + return this; + } + + public withAuthEnabled(authEnabled: boolean): this { + this.authEnabled = authEnabled; + return this; + } + + public withAdminUsername(adminUsername: string): this { + this.adminUsername = adminUsername; + return this; + } + + public withAdminPassword(adminPassword: string): this { + this.adminPassword = adminPassword; + return this; + } + + public override async start(): Promise { + const version2 = isInfluxDB2(this.imageName.tag); + this.withEnvironment(version2 ? this.influxDB2Environment() : this.influxDB1Environment()); + + return new StartedInfluxDBContainer(await super.start(), { + version2, + username: this.username, + password: this.password, + organization: this.organization, + bucket: this.bucket, + adminToken: this.adminToken, + database: this.database, + }); + } + + private influxDB2Environment(): Record { + const environment: 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 !== undefined) { + environment.DOCKER_INFLUXDB_INIT_RETENTION = this.retention; + } + if (this.adminToken !== undefined) { + environment.DOCKER_INFLUXDB_INIT_ADMIN_TOKEN = this.adminToken; + } + return environment; + } + + private influxDB1Environment(): Record { + const environment: Record = { + INFLUXDB_HTTP_AUTH_ENABLED: String(this.authEnabled), + INFLUXDB_ADMIN_USER: this.adminUsername, + INFLUXDB_ADMIN_PASSWORD: this.adminPassword, + INFLUXDB_USER: this.username, + INFLUXDB_USER_PASSWORD: this.password, + }; + if (this.database !== undefined) { + environment.INFLUXDB_DB = this.database; + } + return environment; + } +} + +export class StartedInfluxDBContainer extends AbstractStartedContainer { + constructor( + startedTestContainer: StartedTestContainer, + private readonly connectionDetails: InfluxDBConnectionDetails + ) { + super(startedTestContainer); + } + + public getPort(): number { + return this.getMappedPort(INFLUXDB_PORT); + } + + /** + * @returns the base HTTP URL of the InfluxDB instance, e.g. `http://localhost:32768`. + */ + public getUrl(): string { + return `http://${this.getHost()}:${this.getPort()}`; + } + + /** + * @returns `true` for InfluxDB 2.x, `false` for the legacy 1.x line. + */ + public isInfluxDB2(): boolean { + return this.connectionDetails.version2; + } + + public getUsername(): string { + return this.connectionDetails.username; + } + + public getPassword(): string { + return this.connectionDetails.password; + } + + public getOrganization(): string { + return this.connectionDetails.organization; + } + + public getBucket(): string { + return this.connectionDetails.bucket; + } + + public getAdminToken(): string | undefined { + return this.connectionDetails.adminToken; + } + + public getDatabase(): string | undefined { + return this.connectionDetails.database; + } +} + +function isInfluxDB2(tag: string): boolean { + const majorVersion = Number.parseInt(tag, 10); + return Number.isNaN(majorVersion) || majorVersion >= 2; +} diff --git a/packages/modules/influxdb/tsconfig.build.json b/packages/modules/influxdb/tsconfig.build.json new file mode 100644 index 000000000..e9236a57d --- /dev/null +++ b/packages/modules/influxdb/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "build", + "src/**/*.test.ts" + ], + "references": [ + { + "path": "../../testcontainers" + } + ] +} diff --git a/packages/modules/influxdb/tsconfig.json b/packages/modules/influxdb/tsconfig.json new file mode 100644 index 000000000..0e863cb02 --- /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" + } + ] +}