diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b12a5eb5a..ddbb2a18c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -39,6 +39,7 @@ updates: - "/packages/modules/nats" - "/packages/modules/neo4j" - "/packages/modules/ollama" + - "/packages/modules/opensearch" - "/packages/modules/postgresql" - "/packages/modules/qdrant" - "/packages/modules/rabbitmq" diff --git a/docs/modules/opensearch.md b/docs/modules/opensearch.md new file mode 100644 index 000000000..57dd64ac3 --- /dev/null +++ b/docs/modules/opensearch.md @@ -0,0 +1,23 @@ +# OpenSearch Module + +[OpenSearch](https://opensearch.org/) is a community-driven, open source search and analytics suite derived from Elasticsearch. It provides a distributed, multitenant-capable full-text search engine with an HTTP web interface and schema-free JSON documents. + +## Install + +```bash +npm install @testcontainers/opensearch --save-dev +``` + +## Examples + + +[Create an index:](../../packages/modules/opensearch/src/opensearch-container.test.ts) inside_block:createIndex + + + +[Index a document:](../../packages/modules/opensearch/src/opensearch-container.test.ts) inside_block:indexDocument + + + +[Set a custom password:](../../packages/modules/opensearch/src/opensearch-container.test.ts) inside_block:customPassword + diff --git a/mkdocs.yml b/mkdocs.yml index 230cde8d6..fcbfbfa88 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,6 +74,7 @@ nav: - Nats: modules/nats.md - Neo4J: modules/neo4j.md - Ollama: modules/ollama.md + - OpenSearch: modules/opensearch.md - PostgreSQL: modules/postgresql.md - Qdrant: modules/qdrant.md - RabbitMQ: modules/rabbitmq.md diff --git a/package-lock.json b/package-lock.json index c18943564..668f09786 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5633,6 +5633,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@opensearch-project/opensearch": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@opensearch-project/opensearch/-/opensearch-3.5.1.tgz", + "integrity": "sha512-6bf+HcuERzAtHZxrm6phjref54ABse39BpkDie/YO3AUFMCBrb3SK5okKSdT5n3+nDRuEEQLhQCl0RQV3s1qpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "aws4": "^1.11.0", + "debug": "^4.3.1", + "hpagent": "^1.2.0", + "json11": "^2.0.0", + "ms": "^2.1.3", + "secure-json-parse": "^2.4.0" + }, + "engines": { + "node": ">=14", + "yarn": "^1.22.10" + } + }, + "node_modules/@opensearch-project/opensearch/node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -7142,6 +7168,10 @@ "resolved": "packages/modules/ollama", "link": true }, + "node_modules/@testcontainers/opensearch": { + "resolved": "packages/modules/opensearch", + "link": true + }, "node_modules/@testcontainers/postgresql": { "resolved": "packages/modules/postgresql", "link": true @@ -7763,6 +7793,13 @@ "@types/node": "*" } }, + "node_modules/@types/zxcvbn": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.5.tgz", + "integrity": "sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.35.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", @@ -8924,6 +8961,13 @@ "node": ">= 6.0.0" } }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, "node_modules/axios": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", @@ -13714,6 +13758,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/json11": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/json11/-/json11-2.0.2.tgz", + "integrity": "sha512-HIrd50UPYmP6sqLuLbFVm75g16o0oZrVfxrsY0EEys22klz8mRoWlX9KAEDOSOR9Q34rcxsyC8oDveGrCz5uLQ==", + "dev": true, + "license": "MIT", + "bin": { + "json11": "dist/cli.mjs" + } + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -21157,6 +21211,12 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==", + "license": "MIT" + }, "packages/modules/arangodb": { "name": "@testcontainers/arangodb", "version": "11.2.1", @@ -21594,6 +21654,19 @@ "testcontainers": "^11.2.1" } }, + "packages/modules/opensearch": { + "name": "@testcontainers/opensearch", + "version": "11.2.1", + "license": "MIT", + "dependencies": { + "testcontainers": "^11.2.1", + "zxcvbn": "^4.4.2" + }, + "devDependencies": { + "@opensearch-project/opensearch": "^3.5.1", + "@types/zxcvbn": "^4.4.5" + } + }, "packages/modules/postgresql": { "name": "@testcontainers/postgresql", "version": "11.2.1", diff --git a/packages/modules/opensearch/Dockerfile b/packages/modules/opensearch/Dockerfile new file mode 100644 index 000000000..2835a9cd8 --- /dev/null +++ b/packages/modules/opensearch/Dockerfile @@ -0,0 +1 @@ +FROM opensearchproject/opensearch:3.1.0 \ No newline at end of file diff --git a/packages/modules/opensearch/package.json b/packages/modules/opensearch/package.json new file mode 100644 index 000000000..38430c0a1 --- /dev/null +++ b/packages/modules/opensearch/package.json @@ -0,0 +1,39 @@ +{ + "name": "@testcontainers/opensearch", + "version": "11.2.1", + "license": "MIT", + "keywords": [ + "opensearch", + "testing", + "docker", + "testcontainers" + ], + "description": "OpenSearch 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": { + "@opensearch-project/opensearch": "^3.5.1", + "@types/zxcvbn": "^4.4.5" + }, + "dependencies": { + "testcontainers": "^11.2.1", + "zxcvbn": "^4.4.2" + } +} diff --git a/packages/modules/opensearch/src/index.ts b/packages/modules/opensearch/src/index.ts new file mode 100644 index 000000000..85027621c --- /dev/null +++ b/packages/modules/opensearch/src/index.ts @@ -0,0 +1 @@ +export { OpenSearchContainer, StartedOpenSearchContainer } from "./opensearch-container"; diff --git a/packages/modules/opensearch/src/opensearch-container.test.ts b/packages/modules/opensearch/src/opensearch-container.test.ts new file mode 100644 index 000000000..3c3eb85fd --- /dev/null +++ b/packages/modules/opensearch/src/opensearch-container.test.ts @@ -0,0 +1,105 @@ +import { Client } from "@opensearch-project/opensearch"; +import { getImage } from "../../../testcontainers/src/utils/test-helper"; +import { OpenSearchContainer } from "./opensearch-container"; + +const IMAGE = getImage(__dirname); +const images = ["opensearchproject/opensearch:2.19.2", IMAGE]; + +describe("OpenSearchContainer", { timeout: 180_000 }, () => { + // createIndex { + it.each(images)("should create an index with %s", async (image) => { + const container = await new OpenSearchContainer(image).start(); + const client = new Client({ + node: container.getHttpUrl(), + auth: { + username: container.getUsername(), + password: container.getPassword(), + }, + ssl: { + // trust the self-signed cert + rejectUnauthorized: false, + }, + }); + + await client.indices.create({ index: "people" }); + const existsResponse = await client.indices.exists({ index: "people" }); + expect(existsResponse.body).toBe(true); + await container.stop(); + }); + // } + + // indexDocument { + it("should index a document", async () => { + const container = await new OpenSearchContainer(IMAGE).start(); + const client = new Client({ + node: container.getHttpUrl(), + auth: { + username: container.getUsername(), + password: container.getPassword(), + }, + ssl: { + rejectUnauthorized: false, + }, + }); + + const document = { id: "1", name: "John Doe" }; + + await client.index({ + index: "people", + id: document.id, + body: document, + }); + + const getResponse = await client.get({ index: "people", id: document.id }); + expect(getResponse.body._source).toStrictEqual(document); + await container.stop(); + }); + // } + + it("should work with restarted container", async () => { + const container = await new OpenSearchContainer(IMAGE).start(); + await container.restart(); + + const client = new Client({ + node: container.getHttpUrl(), + auth: { + username: container.getUsername(), + password: container.getPassword(), + }, + ssl: { + rejectUnauthorized: false, + }, + }); + + await client.indices.create({ index: "people" }); + const existsResponse = await client.indices.exists({ index: "people" }); + expect(existsResponse.body).toBe(true); + await container.stop(); + }); + + it("should throw when given an invalid password", () => { + expect(() => new OpenSearchContainer(IMAGE).withPassword("weakpwd")).toThrowError(/Password "weakpwd" is too weak/); + }); + + // customPassword { + it("should set custom password", async () => { + const container = await new OpenSearchContainer(IMAGE).withPassword("Str0ng!Passw0rd2025").start(); + + const client = new Client({ + node: container.getHttpUrl(), + auth: { + username: container.getUsername(), + password: container.getPassword(), + }, + ssl: { + rejectUnauthorized: false, + }, + }); + + await client.indices.create({ index: "people" }); + const existsResponse = await client.indices.exists({ index: "people" }); + expect(existsResponse.body).toBe(true); + await container.stop(); + }); + // } +}); diff --git a/packages/modules/opensearch/src/opensearch-container.ts b/packages/modules/opensearch/src/opensearch-container.ts new file mode 100644 index 000000000..28327c7d8 --- /dev/null +++ b/packages/modules/opensearch/src/opensearch-container.ts @@ -0,0 +1,103 @@ +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; +import zxcvbn from "zxcvbn"; + +const OPENSEARCH_HTTP_PORT = 9200; +const OPENSEARCH_TRANSPORT_PORT = 9300; +const OPENSEARCH_PERFORMANCE_ANALYZER_PORT = 9600; + +export class OpenSearchContainer extends GenericContainer { + // default to security on, with a strong demo password + private securityEnabled = true; + private password = "yourStrong(!)P@ssw0rd"; + private readonly username = "admin"; + + // HTTPS + Basic Auth wait strategy + private readonly defaultWaitStrategy = Wait.forHttp("/", OPENSEARCH_HTTP_PORT) + .usingTls() + .allowInsecure() + .withBasicCredentials(this.username, this.password); + + constructor(image: string) { + super(image); + + this.withExposedPorts(OPENSEARCH_HTTP_PORT, OPENSEARCH_TRANSPORT_PORT, OPENSEARCH_PERFORMANCE_ANALYZER_PORT) + .withEnvironment({ + "discovery.type": "single-node", + // disable security plugin if requested + "plugins.security.disabled": (!this.securityEnabled).toString(), + }) + .withWaitStrategy(this.defaultWaitStrategy) + .withStartupTimeout(120_000); + } + + /** + * Toggle OpenSearch security plugin on/off. + */ + public withSecurityEnabled(enabled: boolean): this { + this.securityEnabled = enabled; + this.withEnvironment({ + "plugins.security.disabled": (!enabled).toString(), + }); + return this; + } + + /** + * Override the 'admin' password. + * Enforces OpenSearch’s requirement of zxcvbn score ≥ 3 + */ + public withPassword(password: string): this { + const { score } = zxcvbn(password); + if (score < 3) { + throw new Error( + `Password "${password}" is too weak (zxcvbn score ${score}). Must score ≥ 3 to meet OpenSearch security requirements.` + ); + } + + this.password = password; + this.defaultWaitStrategy.withBasicCredentials(this.username, this.password); + return this; + } + + /** + * Start the container, injecting the initial-admin-password env var, + * then wrap in our typed StartedOpenSearchContainer. + */ + public override async start(): Promise { + this.withEnvironment({ + OPENSEARCH_INITIAL_ADMIN_PASSWORD: this.password, + }); + + const started = await super.start(); + return new StartedOpenSearchContainer(started, this.username, this.password); + } +} + +export class StartedOpenSearchContainer extends AbstractStartedContainer { + constructor( + override readonly startedTestContainer: StartedTestContainer, + private readonly username: string, + private readonly password: string + ) { + super(startedTestContainer); + } + + /** Mapped HTTP(S) port */ + public getPort(): number { + return this.getMappedPort(OPENSEARCH_HTTP_PORT); + } + + /** HTTPS endpoint URL */ + public getHttpUrl(): string { + return `https://${this.getHost()}:${this.getPort()}`; + } + + /** Admin username (always 'admin' by default) */ + public getUsername(): string { + return this.username; + } + + /** Admin password */ + public getPassword(): string { + return this.password; + } +} diff --git a/packages/modules/opensearch/tsconfig.build.json b/packages/modules/opensearch/tsconfig.build.json new file mode 100644 index 000000000..6b7cddfa0 --- /dev/null +++ b/packages/modules/opensearch/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["build", "src/**/*.test.ts"], + "references": [ + { "path": "../../testcontainers" } + ] +} diff --git a/packages/modules/opensearch/tsconfig.json b/packages/modules/opensearch/tsconfig.json new file mode 100644 index 000000000..75a90f749 --- /dev/null +++ b/packages/modules/opensearch/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "paths": { + "testcontainers": ["../../testcontainers/src"] + } + }, + "exclude": ["build"], + "references": [ + { "path": "../../testcontainers" } + ] +}